4 var website = openerp.website;
5 // $.fn.data automatically parses value, '0'|'1' -> 0|1
7 website.add_template_file('/website/static/src/xml/website.editor.xml');
8 website.dom_ready.done(function () {
9 var is_smartphone = $(document.body)[0].clientWidth < 767;
12 website.ready().then(website.init_editor);
15 $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
16 // Prevent dropdown closing when a contenteditable children is focused
18 && $(ev.target).has(ev.originalEvent.target).length
19 && $(ev.originalEvent.target).is('[contenteditable]')) {
25 function link_dialog(editor) {
26 return new website.editor.RTELinkDialog(editor).appendTo(document.body);
28 function image_dialog(editor) {
29 return new website.editor.RTEImageDialog(editor).appendTo(document.body);
32 // only enable editors manually
33 CKEDITOR.disableAutoInline = true;
34 // EDIT ALL THE THINGS
35 CKEDITOR.dtd.$editable = $.extend(
36 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
37 // Disable removal of empty elements on CKEDITOR activation. Empty
38 // elements are used for e.g. support of FontAwesome icons
39 CKEDITOR.dtd.$removeEmpty = {};
41 website.init_editor = function () {
42 CKEDITOR.plugins.add('customdialogs', {
43 // requires: 'link,image',
44 init: function (editor) {
45 editor.on('doubleclick', function (evt) {
46 var element = evt.data.element;
48 && !element.data('cke-realelement')
49 && !element.isReadOnly()
50 && (element.data('oe-model') !== 'ir.ui.view')) {
55 element = get_selected_link(editor) || evt.data.element;
56 if (element.isReadOnly()
58 || element.data('oe-model')) {
62 editor.getSelection().selectElement(element);
66 var previousSelection;
67 editor.on('selectionChange', function (evt) {
68 var selected = evt.data.path.lastElement;
69 if (previousSelection) {
70 // cleanup previous selection
71 $(previousSelection).next().remove();
72 previousSelection = null;
74 if (!selected.is('img')
75 || selected.data('cke-realelement')
76 || selected.isReadOnly()
77 || selected.data('oe-model') === 'ir.ui.view') {
82 var $el = $(previousSelection = selected.$);
83 var $btn = $('<button type="button" class="btn btn-primary" contenteditable="false">Edit</button>')
91 var position = $el.position();
94 top: $el.height() / 2 + position.top - $btn.outerHeight() / 2,
95 left: $el.width() / 2 + position.left - $btn.outerWidth() / 2,
99 //noinspection JSValidateTypes
100 editor.addCommand('link', {
101 exec: function (editor) {
108 //noinspection JSValidateTypes
109 editor.addCommand('image', {
110 exec: function (editor) {
111 image_dialog(editor);
118 editor.ui.addButton('Link', {
122 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
124 editor.ui.addButton('Image', {
127 toolbar: 'insert,10',
128 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
131 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
134 CKEDITOR.plugins.add( 'tablebutton', {
135 requires: 'panelbutton,floatpanel',
136 init: function( editor ) {
139 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
142 // use existing 'table' icon
144 modes: { wysiwyg: true },
146 // panel opens in iframe, @css is CSS file <link>-ed within
147 // frame document, @attributes are set on iframe itself.
149 css: '/website/static/src/css/editor.css',
150 attributes: { 'role': 'listbox', 'aria-label': label, },
153 onBlock: function (panel, block) {
154 block.autoSize = true;
155 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
160 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
161 var $e = $(e.target);
162 var y = $e.index() + 1;
163 var x = $e.closest('tr').index() + 1;
166 .find('td').removeClass('selected').end()
167 .find('tr:lt(' + String(x) + ')')
168 .children().filter(function () { return $(this).index() < y; })
169 .addClass('selected');
170 }).on('click', 'td', function (e) {
171 var $e = $(e.target);
173 //noinspection JSPotentiallyInvalidConstructorUsage
174 var table = new CKEDITOR.dom.element(
175 $(openerp.qweb.render('website.editor.table', {
176 rows: $e.closest('tr').index() + 1,
177 cols: $e.index() + 1,
180 editor.insertElement(table);
181 setTimeout(function () {
182 //noinspection JSPotentiallyInvalidConstructorUsage
183 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
184 var range = editor.createRange();
185 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
190 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
191 CKEDITOR.ui.fire('ready', this);
197 CKEDITOR.plugins.add('oeref', {
200 init: function (editor) {
201 editor.widgets.add('oeref', {
202 editables: { text: '*' },
204 upcast: function (el) {
205 return el.attributes['data-oe-type']
206 && el.attributes['data-oe-type'] !== 'monetary';
209 editor.widgets.add('monetary', {
210 editables: { text: 'span.oe_currency_value' },
212 upcast: function (el) {
213 return el.attributes['data-oe-type'] === 'monetary';
219 CKEDITOR.plugins.add('bootstrapcombo', {
220 requires: 'richcombo',
222 init: function (editor) {
223 var config = editor.config;
225 editor.ui.addRichCombo('BootstrapLinkCombo', {
229 title: "Link styling",
230 toolbar: 'styles,10',
231 allowedContent: ['a'],
235 '/website/static/lib/bootstrap/css/bootstrap.css',
236 CKEDITOR.skin.getPath( 'editor' )
237 ].concat( config.contentsCss ),
242 'basic': 'btn-default',
243 'primary': 'btn-primary',
244 'success': 'btn-success',
246 'warning': 'btn-warning',
247 'danger': 'btn-danger',
254 'extra small': 'btn-xs',
258 this.add('', 'Reset');
259 this.startGroup("Types");
260 for(var type in this.types) {
261 if (!this.types.hasOwnProperty(type)) { continue; }
262 var cls = this.types[type];
263 var el = _.str.sprintf(
264 '<span class="btn %s">%s</span>',
268 this.startGroup("Sizes");
269 for (var size in this.sizes) {
270 if (!this.sizes.hasOwnProperty(size)) { continue; }
271 cls = this.sizes[size];
274 '<span class="btn btn-default %s">%s</span>',
280 onRender: function () {
282 editor.on('selectionChange', function (e) {
283 var path = e.data.path, el;
285 if (!(el = path.contains('a'))) {
292 // This is crap, but getting the currently selected
293 // element from within onOpen absolutely does not
294 // work, so store the "current" element in the
298 setTimeout(function () {
299 // Because I can't find any normal hook where the
300 // bloody button's bloody element is available
304 onOpen: function () {
308 for(var val in this.types) {
309 if (!this.types.hasOwnProperty(val)) { continue; }
310 var cls = this.types[val];
311 if (!this.element.hasClass(cls)) { continue; }
318 for(val in this.sizes) {
319 if (!this.sizes.hasOwnProperty(val)) { continue; }
320 cls = this.sizes[val];
321 if (!cls || !this.element.hasClass(cls)) { continue; }
327 if (!found && this.element.hasClass('btn')) {
328 this.mark('default');
331 onClick: function (value) {
333 editor.fire('saveShapshot');
336 var el = this.element;
337 if (!el.hasClass('btn')) {
339 el.addClass('btn-default');
343 this.setClass(this.types);
344 this.setClass(this.sizes);
345 el.removeClass('btn');
346 } else if (value in this.types) {
347 this.setClass(this.types, value);
348 } else if (value in this.sizes) {
349 this.setClass(this.sizes, value);
352 editor.fire('saveShapshot');
354 setClass: function (classMap, value) {
355 var element = this.element;
356 _(classMap).each(function (cls) {
357 if (!cls) { return; }
358 element.removeClass(cls);
361 var cls = classMap[value];
363 element.addClass(cls);
370 var editor = new website.EditorBar();
371 var $body = $(document.body);
372 editor.prependTo($body).then(function () {
373 if (location.search.indexOf("enable_editor") >= 0) {
377 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
379 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
380 website.EditorBar = openerp.Widget.extend({
381 template: 'website.editorbar',
383 'click button[data-action=edit]': 'edit',
384 'click button[data-action=save]': 'save',
385 'click button[data-action=cancel]': 'cancel',
388 customize_setup: function() {
390 var view_name = $(document.documentElement).data('view-xmlid');
391 var menu = $('#customize-menu');
392 this.$('#customize-menu-button').click(function(event) {
394 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
396 _.each(result, function (item) {
398 menu.append('<li class="dropdown-header">' + item.name + '</li>');
400 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
401 item.id, item.active ? '' : '-empty', item.name));
404 // Adding Static Menus
405 menu.append('<li class="divider"></li>');
406 menu.append('<li><a data-action="ace" href="#">HTML Editor</a></li>');
407 menu.append('<li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
408 menu.append('<li><a href="/web#action=website.action_module_website">Install Apps</a></li>');
409 self.trigger('rte:customize_menu_ready');
413 menu.on('click', 'a[data-action!=ace]', function (event) {
414 var view_id = $(event.currentTarget).data('view-id');
415 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
417 }).then( function() {
418 window.location.reload();
425 this.saving_mutex = new openerp.Mutex();
427 this.$('#website-top-edit').hide();
428 this.$('#website-top-view').show();
430 $('.dropdown-toggle').dropdown();
431 this.customize_setup();
434 edit: this.$('button[data-action=edit]'),
435 save: this.$('button[data-action=save]'),
436 cancel: this.$('button[data-action=cancel]'),
439 this.rte = new website.RTE(this);
440 this.rte.on('change', this, this.proxy('rte_changed'));
441 this.rte.on('rte:ready', this, function () {
442 self.trigger('rte:ready');
446 this._super.apply(this, arguments),
447 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
451 this.$buttons.edit.prop('disabled', true);
452 this.$('#website-top-view').hide();
453 this.$('#website-top-edit').show();
454 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
456 this.rte.start_edition();
458 rte_changed: function () {
459 this.$buttons.save.prop('disabled', false);
464 observer.disconnect();
465 var editor = this.rte.editor;
466 var root = editor.element.$;
468 // FIXME: select editables then filter by dirty?
469 var defs = this.rte.fetch_editables(root)
471 .removeAttr('contentEditable')
472 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
475 // TODO: Add a queue with concurrency limit in webclient
476 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
477 return self.saving_mutex.exec(function () {
478 return self.saveElement($el)
479 .then(undefined, function (thing, response) {
480 // because ckeditor regenerates all the dom,
481 // we can't just setup the popover here as
482 // everything will be destroyed by the DOM
483 // regeneration. Add markings instead, and
484 // returns a new rejection with all relevant
486 var id = _.uniqueId('carlos_danger_');
487 $el.addClass('oe_dirty oe_carlos_danger');
489 return $.Deferred().reject({
491 error: response.data,
496 return $.when.apply(null, defs).then(function () {
498 }, function (failed) {
499 // If there were errors, re-enable edition
500 self.rte.start_edition(true).then(function () {
501 // jquery's deferred being a pain in the ass
502 if (!_.isArray(failed)) { failed = [failed]; }
504 _(failed).each(function (failure) {
505 $(root).find('.' + failure.id)
506 .removeClass(failure.id)
509 content: failure.error.message,
510 placement: 'auto top',
512 // Force-show popovers so users will notice them.
519 * Saves an RTE content, which always corresponds to a view section (?).
521 saveElement: function ($el) {
522 var markup = $el.prop('outerHTML');
523 return openerp.jsonRpc('/web/dataset/call', 'call', {
526 args: [$el.data('oe-id'), markup,
527 $el.data('oe-xpath') || null,
528 website.get_context()],
531 cancel: function () {
536 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
537 /* ----- RICH TEXT EDITOR ---- */
538 website.RTE = openerp.Widget.extend({
540 id: 'oe_rte_toolbar',
541 className: 'oe_right oe_rte_toolbar',
542 // editor.ui.items -> possible commands &al
543 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
545 init: function (EditorBar) {
546 this.EditorBar = EditorBar;
547 this._super.apply(this, arguments);
551 * In Webkit-based browsers, triple-click will select a paragraph up to
552 * the start of the next "paragraph" including any empty space
553 * inbetween. When said paragraph is removed or altered, it nukes
554 * the empty space and brings part of the content of the next
555 * "paragraph" (which may well be e.g. an image) into the current one,
556 * completely fucking up layouts and breaking snippets.
558 * Try to fuck around with selections on triple-click to attempt to
559 * fix this garbage behavior.
561 * Note: for consistent behavior we may actually want to take over
562 * triple-clicks, in all browsers in order to ensure consistent cross-
563 * platform behavior instead of being at the mercy of rendering engines
564 * & platform selection quirks?
566 webkitSelectionFixer: function (root) {
567 root.addEventListener('click', function (e) {
568 // only webkit seems to have a fucked up behavior, ignore others
569 // FIXME: $.browser goes away in jquery 1.9...
570 if (!$.browser.webkit) { return; }
571 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
572 // The detail attribute indicates the number of times a mouse button has been pressed
573 // we just want the triple click
574 if (e.detail !== 3) { return; }
577 // Get closest block-level element to the triple-clicked
578 // element (using ckeditor's block list because why not)
579 var $closest_block = $(e.target).closest(blocks_selector);
581 // manually set selection range to the content of the
582 // triple-clicked block-level element, to avoid crossing over
583 // between block-level elements
584 document.getSelection().selectAllChildren($closest_block[0]);
587 tableNavigation: function (root) {
589 $(root).on('keydown', function (e) {
591 if (e.which !== 9) { return; }
593 if (self.handleTab(e)) {
599 * Performs whatever operation is necessary on a [TAB] hit, returns
600 * ``true`` if the event's default should be cancelled (if the TAB was
601 * handled by the function)
603 handleTab: function (event) {
604 var forward = !event.shiftKey;
606 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
607 var $cell = $(root).closest('td,th');
609 if (!$cell.length) { return false; }
613 // find cell in same row
614 var row = cell.parentNode;
615 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
617 document.getSelection().selectAllChildren(sibling);
621 // find cell in previous/next row
622 var table = row.parentNode;
623 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
625 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
626 document.getSelection().selectAllChildren(new_cell);
630 // at edge cells, copy word/openoffice behavior: if going backwards
631 // from first cell do nothing, if going forwards from last cell add
634 var row_size = row.cells.length;
635 var new_row = document.createElement('tr');
637 var newcell = document.createElement('td');
639 newcell.textContent = '\u200B';
640 new_row.appendChild(newcell);
642 table.appendChild(new_row);
643 document.getSelection().selectAllChildren(new_row.cells[0]);
649 * Makes the page editable
651 * @param {Boolean} [restart=false] in case the edition was already set
652 * up once and is being re-enabled.
653 * @returns {$.Deferred} deferred indicating when the RTE is ready
655 start_edition: function (restart) {
657 // create a single editor for the whole page
658 var root = document.getElementById('wrapwrap');
660 $(root).on('dragstart', 'img', function (e) {
663 this.webkitSelectionFixer(root);
664 this.tableNavigation(root);
666 var def = $.Deferred();
667 var editor = this.editor = CKEDITOR.inline(root, self._config());
668 editor.on('instanceReady', function () {
669 editor.setReadOnly(false);
670 // ckeditor set root to editable, disable it (only inner
671 // sections are editable)
672 // FIXME: are there cases where the whole editor is editable?
673 editor.editable().setReadOnly(true);
675 self.setup_editables(root);
677 // disable firefox's broken table resizing thing
678 document.execCommand("enableObjectResizing", false, "false");
679 document.execCommand("enableInlineTableEditing", false, "false");
681 self.trigger('rte:ready');
687 setup_editables: function (root) {
688 // selection of editable sub-items was previously in
689 // EditorBar#edit, but for some unknown reason the elements were
690 // apparently removed and recreated (?) at editor initalization,
691 // and observer setup was lost.
693 // setup dirty-marking for each editable element
694 this.fetch_editables(root)
695 .addClass('oe_editable')
699 // only explicitly set contenteditable on view sections,
700 // cke widgets system will do the widgets themselves
701 if ($node.data('oe-model') === 'ir.ui.view') {
702 node.contentEditable = true;
705 observer.observe(node, OBSERVER_CONFIG);
706 $node.one('content_changed', function () {
707 $node.addClass('oe_dirty');
708 self.trigger('change');
713 fetch_editables: function (root) {
714 return $(root).find('[data-oe-model]')
716 .not('.oe_snippet_editor')
717 .filter(function () {
719 // keep view sections and fields which are *not* in
720 // view sections for top-level editables
721 return $this.data('oe-model') === 'ir.ui.view'
722 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
726 _current_editor: function () {
727 return CKEDITOR.currentInstance;
729 _config: function () {
730 // base plugins minus
731 // - magicline (captures mousein/mouseout -> breaks draggable)
732 // - contextmenu & tabletools (disable contextual menu)
733 // - bunch of unused plugins
735 'a11yhelp', 'basicstyles', 'blockquote',
736 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
737 'elementspath', 'enterkey', 'entities', 'filebrowser',
738 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
739 'indentblock', 'indentlist', 'justify',
740 'list', 'pastefromword', 'pastetext', 'preview',
741 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
742 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
747 // Disable auto-generated titles
748 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
750 plugins: plugins.join(','),
752 // FIXME: currently breaks RTE?
753 // Ensure no config file is loaded
756 allowedContent: true,
757 // Don't insert paragraphs around content in e.g. <li>
758 autoParagraph: false,
759 // Don't automatically add or <br> in empty block-level
760 // elements when edition starts
761 fillEmptyBlocks: false,
762 filebrowserImageUploadUrl: "/website/attach",
763 // Support for sharedSpaces in 4.x
764 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
765 // Place toolbar in controlled location
766 sharedSpaces: { top: 'oe_rte_toolbar' },
768 name: 'clipboard', items: [
771 name: 'basicstyles', items: [
772 "Bold", "Italic", "Underline", "Strike", "Subscript",
773 "Superscript", "TextColor", "BGColor", "RemoveFormat"
775 name: 'span', items: [
776 "Link", "Blockquote", "BulletedList",
777 "NumberedList", "Indent", "Outdent"
779 name: 'justify', items: [
780 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
782 name: 'special', items: [
783 "Image", "TableButton"
785 name: 'styles', items: [
786 "Styles", "BootstrapLinkCombo"
789 // styles dropdown in toolbar
791 {name: "Normal", element: 'p'},
792 {name: "Heading 1", element: 'h1'},
793 {name: "Heading 2", element: 'h2'},
794 {name: "Heading 3", element: 'h3'},
795 {name: "Heading 4", element: 'h4'},
796 {name: "Heading 5", element: 'h5'},
797 {name: "Heading 6", element: 'h6'},
798 {name: "Formatted", element: 'pre'},
799 {name: "Address", element: 'address'}
805 website.editor = { };
806 website.editor.Dialog = openerp.Widget.extend({
808 'hidden.bs.modal': 'destroy',
809 'click button.save': 'save',
811 init: function (editor) {
813 this.editor = editor;
816 var sup = this._super();
817 this.$el.modal({backdrop: 'static'});
824 this.$el.modal('hide');
828 website.editor.LinkDialog = website.editor.Dialog.extend({
829 template: 'website.editor.dialog.link',
830 events: _.extend({}, website.editor.Dialog.prototype.events, {
831 'change .url-source': function (e) { this.changed($(e.target)); },
832 'mousedown': function (e) {
833 var $target = $(e.target).closest('.list-group-item');
834 if (!$target.length || $target.hasClass('active')) {
835 // clicked outside groups, or clicked in active groups
839 this.changed($target.find('.url-source'));
841 'click button.remove': 'remove_link',
842 'change input#link-text': function (e) {
843 this.text = $(e.target).val()
846 init: function (editor) {
848 // url -> name mapping for existing pages
849 this.pages = Object.create(null);
855 this.fetch_pages().done(this.proxy('fill_pages')),
862 var self = this, _super = this._super.bind(this);
863 var $e = this.$('.list-group-item.active .url-source');
865 if (!val || !$e[0].checkValidity()) {
866 // FIXME: error message
867 $e.closest('.form-group').addClass('has-error');
873 if ($e.hasClass('email-address')) {
874 this.make_link('mailto:' + val, false, val);
875 } else if ($e.hasClass('existing')) {
876 self.make_link(val, false, this.pages[val]);
877 } else if ($e.hasClass('pages')) {
878 // Create the page, get the URL back
879 done = $.get(_.str.sprintf(
880 '/pagenew/%s?noredirect', encodeURI(val)))
881 .then(function (response) {
882 self.make_link(response, false, val);
885 this.make_link(val, this.$('input.window-new').prop('checked'));
889 make_link: function (url, new_window, label) {
891 bind_data: function (text, href, new_window) {
892 href = href || this.element && (this.element.data( 'cke-saved-href')
893 || this.element.getAttribute('href'));
894 if (!href) { return; }
896 if (new_window === undefined) {
897 new_window = this.element.getAttribute('target') === '_blank';
899 if (text === undefined) {
900 text = this.element.getText();
904 if ((match = /mailto:(.+)/.exec(href))) {
905 $control = this.$('input.email-address').val(match[1]);
906 } else if (href in this.pages) {
907 $control = this.$('select.existing').val(href);
908 } else if ((match = /\/page\/(.+)/.exec(href))) {
909 var actual_href = '/page/website.' + match[1];
910 if (actual_href in this.pages) {
911 $control = this.$('select.existing').val(actual_href);
915 $control = this.$('input.url').val(href);
918 this.changed($control);
920 this.$('input#link-text').val(text);
921 this.$('input.window-new').prop('checked', new_window);
923 changed: function ($e) {
924 this.$('.url-source').not($e).val('');
925 $e.closest('.list-group-item')
927 .siblings().removeClass('active')
928 .addBack().removeClass('has-error');
930 fetch_pages: function () {
931 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
933 method: 'list_pages',
936 context: website.get_context()
940 fill_pages: function (results) {
942 var pages = this.$('select.existing')[0];
943 _(results).each(function (result) {
944 self.pages[result.url] = result.name;
946 pages.options[pages.options.length] =
947 new Option(result.name, result.url);
951 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
954 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
955 this.editor.getSelection().selectElement(element);
957 this.element = element;
959 this.add_removal_button();
962 return this._super();
964 add_removal_button: function () {
965 this.$('.modal-footer').prepend(
967 'website.editor.dialog.link.footer-button'));
969 remove_link: function () {
970 var editor = this.editor;
971 // same issue as in make_link
972 setTimeout(function () {
973 editor.removeStyle(new CKEDITOR.style({
975 type: CKEDITOR.STYLE_INLINE,
976 alwaysRemoveElement: true,
982 * Greatly simplified version of CKEDITOR's
983 * plugins.link.dialogs.link.onOk.
985 * @param {String} url
986 * @param {Boolean} [new_window=false]
987 * @param {String} [label=null]
989 make_link: function (url, new_window, label) {
990 var attributes = {href: url, 'data-cke-saved-href': url};
993 attributes['target'] = '_blank';
995 to_remove.push('target');
999 this.element.setAttributes(attributes);
1000 this.element.removeAttributes(to_remove);
1001 if (this.text) { this.element.setText(this.text); }
1003 var selection = this.editor.getSelection();
1004 var range = selection.getRanges(true)[0];
1006 if (range.collapsed) {
1007 //noinspection JSPotentiallyInvalidConstructorUsage
1008 var text = new CKEDITOR.dom.text(
1009 this.text || label || url);
1010 range.insertNode(text);
1011 range.selectNodeContents(text);
1014 //noinspection JSPotentiallyInvalidConstructorUsage
1015 new CKEDITOR.style({
1016 type: CKEDITOR.STYLE_INLINE,
1018 attributes: attributes,
1019 }).applyToRange(range);
1021 // focus dance between RTE & dialog blow up the stack in Safari
1022 // and Chrome, so defer select() until dialog has been closed
1023 setTimeout(function () {
1029 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1030 * if the editor is set directly on a link it will thus not work.
1032 get_selected_link: function () {
1033 return get_selected_link(this.editor);
1038 * ImageDialog widget. Lets users change an image, including uploading a
1039 * new image in OpenERP or selecting the image style (if supported by
1042 * Initialized as usual, but the caller can hook into two events:
1044 * @event start({url, style}) called during dialog initialization and
1045 * opening, the handler can *set* the ``url``
1046 * and ``style`` properties on its parameter
1047 * to provide these as default values to the
1049 * @event save({url, style}) called during dialog finalization, the handler
1050 * is provided with the image url and style
1051 * selected by the users (or possibly the ones
1052 * originally passed in)
1054 website.editor.ImageDialog = website.editor.Dialog.extend({
1055 template: 'website.editor.dialog.image',
1056 events: _.extend({}, website.editor.Dialog.prototype.events, {
1057 'change .url-source': function (e) { this.changed($(e.target)); },
1058 'click button.filepicker': function () {
1059 this.$('input[type=file]').click();
1061 'change input[type=file]': 'file_selection',
1062 'change input.url': 'preview_image',
1063 'click a[href=#existing]': 'browse_existing',
1064 'change select.image-style': 'preview_image',
1067 start: function () {
1068 this.$('.modal-footer [disabled]').text("Uploading…");
1069 var $options = this.$('.image-style').children();
1070 this.image_styles = $options.map(function () { return this.value; }).get();
1072 var o = { url: null, style: null, };
1073 // avoid typos, prevent addition of new properties to the object
1074 Object.preventExtensions(o);
1075 this.trigger('start', o);
1079 this.$('.image-style').val(o.style);
1081 this.set_image(o.url);
1084 return this._super();
1087 this.trigger('save', {
1088 url: this.$('input.url').val(),
1089 style: this.$('.image-style').val(),
1091 return this._super();
1095 * Sets the provided image url as the dialog's value-to-save and
1096 * refreshes the preview element to use it.
1098 set_image: function (url) {
1099 this.$('input.url').val(url);
1100 this.preview_image();
1103 file_selection: function () {
1104 this.$el.addClass('nosave');
1105 this.$('button.filepicker').removeClass('btn-danger btn-success');
1108 var callback = _.uniqueId('func_');
1109 this.$('input[name=func]').val(callback);
1111 window[callback] = function (url, error) {
1112 delete window[callback];
1113 self.file_selected(url, error);
1115 this.$('form').submit();
1117 file_selected: function(url, error) {
1118 var $button = this.$('button.filepicker');
1120 $button.addClass('btn-danger');
1123 $button.addClass('btn-success');
1124 this.set_image(url);
1126 preview_image: function () {
1127 this.$el.removeClass('nosave');
1128 var image = this.$('input.url').val();
1129 if (!image) { return; }
1131 this.$('img.image-preview')
1133 .removeClass(this.image_styles.join(' '))
1134 .addClass(this.$('select.image-style').val());
1136 browse_existing: function (e) {
1138 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1141 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1143 this._super.apply(this, arguments);
1145 this.on('start', this, this.proxy('started'));
1146 this.on('save', this, this.proxy('saved'));
1148 started: function (holder) {
1149 var selection = this.editor.getSelection();
1150 var el = selection && selection.getSelectedElement();
1151 this.element = null;
1153 if (el && el.is('img')) {
1155 _(this.image_styles).each(function (style) {
1156 if (el.hasClass(style)) {
1157 holder.style = style;
1160 holder.url = el.getAttribute('src');
1163 saved: function (data) {
1164 var element, editor = this.editor;
1165 if (!(element = this.element)) {
1166 element = editor.document.createElement('img');
1167 element.addClass('img');
1168 // focus event handler interactions between bootstrap (modal)
1169 // and ckeditor (RTE) lead to blowing the stack in Safari and
1170 // Chrome (but not FF) when this is done synchronously =>
1171 // defer insertion so modal has been hidden & destroyed before
1173 setTimeout(function () {
1174 editor.insertElement(element);
1178 var style = data.style;
1179 element.setAttribute('src', data.url);
1180 element.removeAttribute('data-cke-saved-src');
1181 $(element.$).removeClass(this.image_styles.join(' '));
1182 if (style) { element.addClass(style); }
1186 var IMAGES_PER_ROW = 6;
1187 var IMAGES_ROWS = 4;
1188 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1189 template: 'website.editor.dialog.image.existing',
1190 events: _.extend({}, website.editor.Dialog.prototype.events, {
1191 'click .existing-attachments img': 'select_existing',
1192 'click .pager > li': function (e) {
1194 var $target = $(e.currentTarget);
1195 if ($target.hasClass('disabled')) {
1198 this.page += $target.hasClass('previous') ? -1 : 1;
1199 this.display_attachments();
1202 init: function (parent) {
1205 this.parent = parent;
1206 this._super(parent.editor);
1209 start: function () {
1212 this.fetch_existing().then(this.proxy('fetched_existing')));
1215 fetch_existing: function () {
1216 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1217 model: 'ir.attachment',
1218 method: 'search_read',
1221 fields: ['name', 'website_url'],
1222 domain: [['res_model', '=', 'ir.ui.view']],
1224 context: website.get_context(),
1228 fetched_existing: function (records) {
1229 this.records = records;
1230 this.display_attachments();
1232 display_attachments: function () {
1233 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1235 var from = this.page * per_screen;
1236 var records = this.records;
1238 // Create rows of 3 records
1239 var rows = _(records).chain()
1240 .slice(from, from + per_screen)
1241 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1245 this.$('.existing-attachments').replaceWith(
1246 openerp.qweb.render(
1247 'website.editor.dialog.image.existing.content', {rows: rows}));
1249 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1250 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1253 select_existing: function (e) {
1254 var link = $(e.currentTarget).attr('src');
1256 this.parent.set_image(link);
1262 function get_selected_link(editor) {
1263 var sel = editor.getSelection(),
1264 el = sel.getSelectedElement();
1265 if (el && el.is('a')) { return el; }
1267 var range = sel.getRanges(true)[0];
1268 if (!range) { return null; }
1270 range.shrink(CKEDITOR.SHRINK_TEXT);
1271 var commonAncestor = range.getCommonAncestor();
1272 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1273 return element.data('oe-model') === 'ir.ui.view'
1275 if (!viewRoot) { return null; }
1276 // if viewRoot is the first link, don't edit it.
1277 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1278 .contains('a', true);
1282 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1283 var OBSERVER_CONFIG = {
1286 characterData: true,
1288 attributeOldValue: true,
1290 var observer = new website.Observer(function (mutations) {
1291 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1292 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1293 // will not mark dirty on attribute changes (@class, img/@src,
1295 _(mutations).chain()
1296 .filter(function (m) {
1298 case 'attributes': // ignore .cke_focus being added or removed
1299 // if attribute is not a class, can't be .cke_focus change
1300 if (m.attributeName !== 'class') { return true; }
1302 // find out what classes were added or removed
1303 var oldClasses = (m.oldValue || '').split(/\s+/);
1304 var newClasses = m.target.className.split(/\s+/);
1305 var change = _.union(_.difference(oldClasses, newClasses),
1306 _.difference(newClasses, oldClasses));
1307 // ignore mutation if the *only* change is .cke_focus
1308 return change.length !== 1 || change[0] === 'cke_focus';
1310 // <br type="_moz"> appears when focusing RTE in FF, ignore
1311 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1317 var node = m.target;
1318 while (node && !$(node).hasClass('oe_editable')) {
1319 node = node.parentNode;
1321 $(m.target).trigger('node_changed');
1326 .each(function (node) { $(node).trigger('content_changed'); })