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 self.trigger('rte:customize_menu_ready');
412 menu.on('click', 'a[data-action!=ace]', function (event) {
413 var view_id = $(event.currentTarget).data('view-id');
414 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
416 }).then( function() {
417 window.location.reload();
424 this.saving_mutex = new openerp.Mutex();
426 this.$('#website-top-edit').hide();
427 this.$('#website-top-view').show();
429 $('.dropdown-toggle').dropdown();
430 this.customize_setup();
433 edit: this.$('button[data-action=edit]'),
434 save: this.$('button[data-action=save]'),
435 cancel: this.$('button[data-action=cancel]'),
438 this.rte = new website.RTE(this);
439 this.rte.on('change', this, this.proxy('rte_changed'));
440 this.rte.on('rte:ready', this, function () {
441 self.trigger('rte:ready');
445 this._super.apply(this, arguments),
446 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
450 this.$buttons.edit.prop('disabled', true);
451 this.$('#website-top-view').hide();
452 this.$('#website-top-edit').show();
453 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
455 this.rte.start_edition();
457 rte_changed: function () {
458 this.$buttons.save.prop('disabled', false);
463 observer.disconnect();
464 var editor = this.rte.editor;
465 var root = editor.element.$;
467 // FIXME: select editables then filter by dirty?
468 var defs = this.rte.fetch_editables(root)
470 .removeAttr('contentEditable')
471 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
474 // TODO: Add a queue with concurrency limit in webclient
475 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
476 return self.saving_mutex.exec(function () {
477 return self.saveElement($el)
478 .then(undefined, function (thing, response) {
479 // because ckeditor regenerates all the dom,
480 // we can't just setup the popover here as
481 // everything will be destroyed by the DOM
482 // regeneration. Add markings instead, and
483 // returns a new rejection with all relevant
485 var id = _.uniqueId('carlos_danger_');
486 $el.addClass('oe_dirty oe_carlos_danger');
488 return $.Deferred().reject({
490 error: response.data,
495 return $.when.apply(null, defs).then(function () {
497 }, function (failed) {
498 // If there were errors, re-enable edition
499 self.rte.start_edition(true).then(function () {
500 // jquery's deferred being a pain in the ass
501 if (!_.isArray(failed)) { failed = [failed]; }
503 _(failed).each(function (failure) {
504 $(root).find('.' + failure.id)
505 .removeClass(failure.id)
508 content: failure.error.message,
509 placement: 'auto top',
511 // Force-show popovers so users will notice them.
518 * Saves an RTE content, which always corresponds to a view section (?).
520 saveElement: function ($el) {
521 var markup = $el.prop('outerHTML');
522 return openerp.jsonRpc('/web/dataset/call', 'call', {
525 args: [$el.data('oe-id'), markup,
526 $el.data('oe-xpath') || null,
527 website.get_context()],
530 cancel: function () {
535 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
536 /* ----- RICH TEXT EDITOR ---- */
537 website.RTE = openerp.Widget.extend({
539 id: 'oe_rte_toolbar',
540 className: 'oe_right oe_rte_toolbar',
541 // editor.ui.items -> possible commands &al
542 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
544 init: function (EditorBar) {
545 this.EditorBar = EditorBar;
546 this._super.apply(this, arguments);
550 * In Webkit-based browsers, triple-click will select a paragraph up to
551 * the start of the next "paragraph" including any empty space
552 * inbetween. When said paragraph is removed or altered, it nukes
553 * the empty space and brings part of the content of the next
554 * "paragraph" (which may well be e.g. an image) into the current one,
555 * completely fucking up layouts and breaking snippets.
557 * Try to fuck around with selections on triple-click to attempt to
558 * fix this garbage behavior.
560 * Note: for consistent behavior we may actually want to take over
561 * triple-clicks, in all browsers in order to ensure consistent cross-
562 * platform behavior instead of being at the mercy of rendering engines
563 * & platform selection quirks?
565 webkitSelectionFixer: function (root) {
566 root.addEventListener('click', function (e) {
567 // only webkit seems to have a fucked up behavior, ignore others
568 // FIXME: $.browser goes away in jquery 1.9...
569 if (!$.browser.webkit) { return; }
570 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
571 // The detail attribute indicates the number of times a mouse button has been pressed
572 // we just want the triple click
573 if (e.detail !== 3) { return; }
576 // Get closest block-level element to the triple-clicked
577 // element (using ckeditor's block list because why not)
578 var $closest_block = $(e.target).closest(blocks_selector);
580 // manually set selection range to the content of the
581 // triple-clicked block-level element, to avoid crossing over
582 // between block-level elements
583 document.getSelection().selectAllChildren($closest_block[0]);
586 tableNavigation: function (root) {
588 $(root).on('keydown', function (e) {
590 if (e.which !== 9) { return; }
592 if (self.handleTab(e)) {
598 * Performs whatever operation is necessary on a [TAB] hit, returns
599 * ``true`` if the event's default should be cancelled (if the TAB was
600 * handled by the function)
602 handleTab: function (event) {
603 var forward = !event.shiftKey;
605 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
606 var $cell = $(root).closest('td,th');
608 if (!$cell.length) { return false; }
612 // find cell in same row
613 var row = cell.parentNode;
614 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
616 document.getSelection().selectAllChildren(sibling);
620 // find cell in previous/next row
621 var table = row.parentNode;
622 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
624 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
625 document.getSelection().selectAllChildren(new_cell);
629 // at edge cells, copy word/openoffice behavior: if going backwards
630 // from first cell do nothing, if going forwards from last cell add
633 var row_size = row.cells.length;
634 var new_row = document.createElement('tr');
636 var newcell = document.createElement('td');
638 newcell.textContent = '\u200B';
639 new_row.appendChild(newcell);
641 table.appendChild(new_row);
642 document.getSelection().selectAllChildren(new_row.cells[0]);
648 * Makes the page editable
650 * @param {Boolean} [restart=false] in case the edition was already set
651 * up once and is being re-enabled.
652 * @returns {$.Deferred} deferred indicating when the RTE is ready
654 start_edition: function (restart) {
656 // create a single editor for the whole page
657 var root = document.getElementById('wrapwrap');
659 $(root).on('dragstart', 'img', function (e) {
662 this.webkitSelectionFixer(root);
663 this.tableNavigation(root);
665 var def = $.Deferred();
666 var editor = this.editor = CKEDITOR.inline(root, self._config());
667 editor.on('instanceReady', function () {
668 editor.setReadOnly(false);
669 // ckeditor set root to editable, disable it (only inner
670 // sections are editable)
671 // FIXME: are there cases where the whole editor is editable?
672 editor.editable().setReadOnly(true);
674 self.setup_editables(root);
676 // disable firefox's broken table resizing thing
677 document.execCommand("enableObjectResizing", false, "false");
678 document.execCommand("enableInlineTableEditing", false, "false");
680 self.trigger('rte:ready');
686 setup_editables: function (root) {
687 // selection of editable sub-items was previously in
688 // EditorBar#edit, but for some unknown reason the elements were
689 // apparently removed and recreated (?) at editor initalization,
690 // and observer setup was lost.
692 // setup dirty-marking for each editable element
693 this.fetch_editables(root)
694 .addClass('oe_editable')
698 // only explicitly set contenteditable on view sections,
699 // cke widgets system will do the widgets themselves
700 if ($node.data('oe-model') === 'ir.ui.view') {
701 node.contentEditable = true;
704 observer.observe(node, OBSERVER_CONFIG);
705 $node.one('content_changed', function () {
706 $node.addClass('oe_dirty');
707 self.trigger('change');
712 fetch_editables: function (root) {
713 return $(root).find('[data-oe-model]')
715 .not('.oe_snippet_editor')
716 .filter(function () {
718 // keep view sections and fields which are *not* in
719 // view sections for top-level editables
720 return $this.data('oe-model') === 'ir.ui.view'
721 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
725 _current_editor: function () {
726 return CKEDITOR.currentInstance;
728 _config: function () {
729 // base plugins minus
730 // - magicline (captures mousein/mouseout -> breaks draggable)
731 // - contextmenu & tabletools (disable contextual menu)
732 // - bunch of unused plugins
734 'a11yhelp', 'basicstyles', 'blockquote',
735 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
736 'elementspath', 'enterkey', 'entities', 'filebrowser',
737 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
738 'indentblock', 'indentlist', 'justify',
739 'list', 'pastefromword', 'pastetext', 'preview',
740 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
741 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
746 // Disable auto-generated titles
747 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
749 plugins: plugins.join(','),
751 // FIXME: currently breaks RTE?
752 // Ensure no config file is loaded
755 allowedContent: true,
756 // Don't insert paragraphs around content in e.g. <li>
757 autoParagraph: false,
758 // Don't automatically add or <br> in empty block-level
759 // elements when edition starts
760 fillEmptyBlocks: false,
761 filebrowserImageUploadUrl: "/website/attach",
762 // Support for sharedSpaces in 4.x
763 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
764 // Place toolbar in controlled location
765 sharedSpaces: { top: 'oe_rte_toolbar' },
767 name: 'clipboard', items: [
770 name: 'basicstyles', items: [
771 "Bold", "Italic", "Underline", "Strike", "Subscript",
772 "Superscript", "TextColor", "BGColor", "RemoveFormat"
774 name: 'span', items: [
775 "Link", "Blockquote", "BulletedList",
776 "NumberedList", "Indent", "Outdent"
778 name: 'justify', items: [
779 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
781 name: 'special', items: [
782 "Image", "TableButton"
784 name: 'styles', items: [
785 "Styles", "BootstrapLinkCombo"
788 // styles dropdown in toolbar
790 {name: "Normal", element: 'p'},
791 {name: "Heading 1", element: 'h1'},
792 {name: "Heading 2", element: 'h2'},
793 {name: "Heading 3", element: 'h3'},
794 {name: "Heading 4", element: 'h4'},
795 {name: "Heading 5", element: 'h5'},
796 {name: "Heading 6", element: 'h6'},
797 {name: "Formatted", element: 'pre'},
798 {name: "Address", element: 'address'}
804 website.editor = { };
805 website.editor.Dialog = openerp.Widget.extend({
807 'hidden.bs.modal': 'destroy',
808 'click button.save': 'save',
810 init: function (editor) {
812 this.editor = editor;
815 var sup = this._super();
816 this.$el.modal({backdrop: 'static'});
823 this.$el.modal('hide');
827 website.editor.LinkDialog = website.editor.Dialog.extend({
828 template: 'website.editor.dialog.link',
829 events: _.extend({}, website.editor.Dialog.prototype.events, {
830 'change .url-source': function (e) { this.changed($(e.target)); },
831 'mousedown': function (e) {
832 var $target = $(e.target).closest('.list-group-item');
833 if (!$target.length || $target.hasClass('active')) {
834 // clicked outside groups, or clicked in active groups
838 this.changed($target.find('.url-source'));
840 'click button.remove': 'remove_link',
841 'change input#link-text': function (e) {
842 this.text = $(e.target).val()
845 init: function (editor) {
847 // url -> name mapping for existing pages
848 this.pages = Object.create(null);
854 this.fetch_pages().done(this.proxy('fill_pages')),
861 var self = this, _super = this._super.bind(this);
862 var $e = this.$('.list-group-item.active .url-source');
864 if (!val || !$e[0].checkValidity()) {
865 // FIXME: error message
866 $e.closest('.form-group').addClass('has-error');
872 if ($e.hasClass('email-address')) {
873 this.make_link('mailto:' + val, false, val);
874 } else if ($e.hasClass('existing')) {
875 self.make_link(val, false, this.pages[val]);
876 } else if ($e.hasClass('pages')) {
877 // Create the page, get the URL back
878 done = $.get(_.str.sprintf(
879 '/pagenew/%s?noredirect', encodeURI(val)))
880 .then(function (response) {
881 self.make_link(response, false, val);
884 this.make_link(val, this.$('input.window-new').prop('checked'));
888 make_link: function (url, new_window, label) {
890 bind_data: function (text, href, new_window) {
891 href = href || this.element && (this.element.data( 'cke-saved-href')
892 || this.element.getAttribute('href'));
893 if (!href) { return; }
895 if (new_window === undefined) {
896 new_window = this.element.getAttribute('target') === '_blank';
898 if (text === undefined) {
899 text = this.element.getText();
903 if ((match = /mailto:(.+)/.exec(href))) {
904 $control = this.$('input.email-address').val(match[1]);
905 } else if (href in this.pages) {
906 $control = this.$('select.existing').val(href);
907 } else if ((match = /\/page\/(.+)/.exec(href))) {
908 var actual_href = '/page/website.' + match[1];
909 if (actual_href in this.pages) {
910 $control = this.$('select.existing').val(actual_href);
914 $control = this.$('input.url').val(href);
917 this.changed($control);
919 this.$('input#link-text').val(text);
920 this.$('input.window-new').prop('checked', new_window);
922 changed: function ($e) {
923 this.$('.url-source').not($e).val('');
924 $e.closest('.list-group-item')
926 .siblings().removeClass('active')
927 .addBack().removeClass('has-error');
929 fetch_pages: function () {
930 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
932 method: 'list_pages',
935 context: website.get_context()
939 fill_pages: function (results) {
941 var pages = this.$('select.existing')[0];
942 _(results).each(function (result) {
943 self.pages[result.url] = result.name;
945 pages.options[pages.options.length] =
946 new Option(result.name, result.url);
950 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
953 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
954 this.editor.getSelection().selectElement(element);
956 this.element = element;
958 this.add_removal_button();
961 return this._super();
963 add_removal_button: function () {
964 this.$('.modal-footer').prepend(
966 'website.editor.dialog.link.footer-button'));
968 remove_link: function () {
969 var editor = this.editor;
970 // same issue as in make_link
971 setTimeout(function () {
972 editor.removeStyle(new CKEDITOR.style({
974 type: CKEDITOR.STYLE_INLINE,
975 alwaysRemoveElement: true,
981 * Greatly simplified version of CKEDITOR's
982 * plugins.link.dialogs.link.onOk.
984 * @param {String} url
985 * @param {Boolean} [new_window=false]
986 * @param {String} [label=null]
988 make_link: function (url, new_window, label) {
989 var attributes = {href: url, 'data-cke-saved-href': url};
992 attributes['target'] = '_blank';
994 to_remove.push('target');
998 this.element.setAttributes(attributes);
999 this.element.removeAttributes(to_remove);
1000 if (this.text) { this.element.setText(this.text); }
1002 var selection = this.editor.getSelection();
1003 var range = selection.getRanges(true)[0];
1005 if (range.collapsed) {
1006 //noinspection JSPotentiallyInvalidConstructorUsage
1007 var text = new CKEDITOR.dom.text(
1008 this.text || label || url);
1009 range.insertNode(text);
1010 range.selectNodeContents(text);
1013 //noinspection JSPotentiallyInvalidConstructorUsage
1014 new CKEDITOR.style({
1015 type: CKEDITOR.STYLE_INLINE,
1017 attributes: attributes,
1018 }).applyToRange(range);
1020 // focus dance between RTE & dialog blow up the stack in Safari
1021 // and Chrome, so defer select() until dialog has been closed
1022 setTimeout(function () {
1028 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1029 * if the editor is set directly on a link it will thus not work.
1031 get_selected_link: function () {
1032 return get_selected_link(this.editor);
1037 * ImageDialog widget. Lets users change an image, including uploading a
1038 * new image in OpenERP or selecting the image style (if supported by
1041 * Initialized as usual, but the caller can hook into two events:
1043 * @event start({url, style}) called during dialog initialization and
1044 * opening, the handler can *set* the ``url``
1045 * and ``style`` properties on its parameter
1046 * to provide these as default values to the
1048 * @event save({url, style}) called during dialog finalization, the handler
1049 * is provided with the image url and style
1050 * selected by the users (or possibly the ones
1051 * originally passed in)
1053 website.editor.ImageDialog = website.editor.Dialog.extend({
1054 template: 'website.editor.dialog.image',
1055 events: _.extend({}, website.editor.Dialog.prototype.events, {
1056 'change .url-source': function (e) { this.changed($(e.target)); },
1057 'click button.filepicker': function () {
1058 this.$('input[type=file]').click();
1060 'change input[type=file]': 'file_selection',
1061 'change input.url': 'preview_image',
1062 'click a[href=#existing]': 'browse_existing',
1063 'change select.image-style': 'preview_image',
1066 start: function () {
1067 this.$('.modal-footer [disabled]').text("Uploading…");
1068 var $options = this.$('.image-style').children();
1069 this.image_styles = $options.map(function () { return this.value; }).get();
1071 var o = { url: null, style: null, };
1072 // avoid typos, prevent addition of new properties to the object
1073 Object.preventExtensions(o);
1074 this.trigger('start', o);
1078 this.$('.image-style').val(o.style);
1080 this.set_image(o.url);
1083 return this._super();
1086 this.trigger('save', {
1087 url: this.$('input.url').val(),
1088 style: this.$('.image-style').val(),
1090 return this._super();
1094 * Sets the provided image url as the dialog's value-to-save and
1095 * refreshes the preview element to use it.
1097 set_image: function (url) {
1098 this.$('input.url').val(url);
1099 this.preview_image();
1102 file_selection: function () {
1103 this.$el.addClass('nosave');
1104 this.$('button.filepicker').removeClass('btn-danger btn-success');
1107 var callback = _.uniqueId('func_');
1108 this.$('input[name=func]').val(callback);
1110 window[callback] = function (url, error) {
1111 delete window[callback];
1112 self.file_selected(url, error);
1114 this.$('form').submit();
1116 file_selected: function(url, error) {
1117 var $button = this.$('button.filepicker');
1119 $button.addClass('btn-danger');
1122 $button.addClass('btn-success');
1123 this.set_image(url);
1125 preview_image: function () {
1126 this.$el.removeClass('nosave');
1127 var image = this.$('input.url').val();
1128 if (!image) { return; }
1130 this.$('img.image-preview')
1132 .removeClass(this.image_styles.join(' '))
1133 .addClass(this.$('select.image-style').val());
1135 browse_existing: function (e) {
1137 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1140 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1142 this._super.apply(this, arguments);
1144 this.on('start', this, this.proxy('started'));
1145 this.on('save', this, this.proxy('saved'));
1147 started: function (holder) {
1148 var selection = this.editor.getSelection();
1149 var el = selection && selection.getSelectedElement();
1150 this.element = null;
1152 if (el && el.is('img')) {
1154 _(this.image_styles).each(function (style) {
1155 if (el.hasClass(style)) {
1156 holder.style = style;
1159 holder.url = el.getAttribute('src');
1162 saved: function (data) {
1163 var element, editor = this.editor;
1164 if (!(element = this.element)) {
1165 element = editor.document.createElement('img');
1166 element.addClass('img');
1167 // focus event handler interactions between bootstrap (modal)
1168 // and ckeditor (RTE) lead to blowing the stack in Safari and
1169 // Chrome (but not FF) when this is done synchronously =>
1170 // defer insertion so modal has been hidden & destroyed before
1172 setTimeout(function () {
1173 editor.insertElement(element);
1177 var style = data.style;
1178 element.setAttribute('src', data.url);
1179 element.removeAttribute('data-cke-saved-src');
1180 $(element.$).removeClass(this.image_styles.join(' '));
1181 if (style) { element.addClass(style); }
1185 var IMAGES_PER_ROW = 6;
1186 var IMAGES_ROWS = 4;
1187 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1188 template: 'website.editor.dialog.image.existing',
1189 events: _.extend({}, website.editor.Dialog.prototype.events, {
1190 'click .existing-attachments img': 'select_existing',
1191 'click .pager > li': function (e) {
1193 var $target = $(e.currentTarget);
1194 if ($target.hasClass('disabled')) {
1197 this.page += $target.hasClass('previous') ? -1 : 1;
1198 this.display_attachments();
1201 init: function (parent) {
1204 this.parent = parent;
1205 this._super(parent.editor);
1208 start: function () {
1211 this.fetch_existing().then(this.proxy('fetched_existing')));
1214 fetch_existing: function () {
1215 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1216 model: 'ir.attachment',
1217 method: 'search_read',
1220 fields: ['name', 'website_url'],
1221 domain: [['res_model', '=', 'ir.ui.view']],
1223 context: website.get_context(),
1227 fetched_existing: function (records) {
1228 this.records = records;
1229 this.display_attachments();
1231 display_attachments: function () {
1232 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1234 var from = this.page * per_screen;
1235 var records = this.records;
1237 // Create rows of 3 records
1238 var rows = _(records).chain()
1239 .slice(from, from + per_screen)
1240 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1244 this.$('.existing-attachments').replaceWith(
1245 openerp.qweb.render(
1246 'website.editor.dialog.image.existing.content', {rows: rows}));
1248 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1249 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1252 select_existing: function (e) {
1253 var link = $(e.currentTarget).attr('src');
1255 this.parent.set_image(link);
1261 function get_selected_link(editor) {
1262 var sel = editor.getSelection(),
1263 el = sel.getSelectedElement();
1264 if (el && el.is('a')) { return el; }
1266 var range = sel.getRanges(true)[0];
1267 if (!range) { return null; }
1269 range.shrink(CKEDITOR.SHRINK_TEXT);
1270 var commonAncestor = range.getCommonAncestor();
1271 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1272 return element.data('oe-model') === 'ir.ui.view'
1274 if (!viewRoot) { return null; }
1275 // if viewRoot is the first link, don't edit it.
1276 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1277 .contains('a', true);
1281 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1282 var OBSERVER_CONFIG = {
1285 characterData: true,
1287 attributeOldValue: true,
1289 var observer = new website.Observer(function (mutations) {
1290 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1291 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1292 // will not mark dirty on attribute changes (@class, img/@src,
1294 _(mutations).chain()
1295 .filter(function (m) {
1297 case 'attributes': // ignore .cke_focus being added or removed
1298 // if attribute is not a class, can't be .cke_focus change
1299 if (m.attributeName !== 'class') { return true; }
1301 // find out what classes were added or removed
1302 var oldClasses = (m.oldValue || '').split(/\s+/);
1303 var newClasses = m.target.className.split(/\s+/);
1304 var change = _.union(_.difference(oldClasses, newClasses),
1305 _.difference(newClasses, oldClasses));
1306 // ignore mutation if the *only* change is .cke_focus
1307 return change.length !== 1 || change[0] === 'cke_focus';
1309 // <br type="_moz"> appears when focusing RTE in FF, ignore
1310 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1316 var node = m.target;
1317 while (node && !$(node).hasClass('oe_editable')) {
1318 node = node.parentNode;
1320 $(m.target).trigger('node_changed');
1325 .each(function (node) { $(node).trigger('content_changed'); })