4 var website = openerp.website;
5 // $.fn.data automatically parses value, '0'|'1' -> 0|1
7 website.templates.push('/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);
16 function link_dialog(editor) {
17 return new website.editor.LinkDialog(editor).appendTo(document.body);
19 function image_dialog(editor) {
20 return new website.editor.RTEImageDialog(editor).appendTo(document.body);
23 // only enable editors manually
24 CKEDITOR.disableAutoInline = true;
25 // EDIT ALL THE THINGS
26 CKEDITOR.dtd.$editable = $.extend(
27 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
28 // Disable removal of empty elements on CKEDITOR activation. Empty
29 // elements are used for e.g. support of FontAwesome icons
30 CKEDITOR.dtd.$removeEmpty = {};
32 website.init_editor = function () {
33 CKEDITOR.plugins.add('customdialogs', {
34 // requires: 'link,image',
35 init: function (editor) {
36 editor.on('doubleclick', function (evt) {
37 var element = evt.data.element;
39 && !element.data('cke-realelement')
40 && !element.isReadOnly()
41 && (element.data('oe-model') !== 'ir.ui.view')) {
46 element = get_selected_link(editor) || evt.data.element;
47 if (element.isReadOnly()
49 || element.data('oe-model')) {
53 editor.getSelection().selectElement(element);
57 //noinspection JSValidateTypes
58 editor.addCommand('link', {
59 exec: function (editor) {
66 //noinspection JSValidateTypes
67 editor.addCommand('image', {
68 exec: function (editor) {
76 editor.ui.addButton('Link', {
80 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
82 editor.ui.addButton('Image', {
86 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
89 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
92 CKEDITOR.plugins.add( 'tablebutton', {
93 requires: 'panelbutton,floatpanel',
94 init: function( editor ) {
97 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
100 // use existing 'table' icon
102 modes: { wysiwyg: true },
104 // panel opens in iframe, @css is CSS file <link>-ed within
105 // frame document, @attributes are set on iframe itself.
107 css: '/website/static/src/css/editor.css',
108 attributes: { 'role': 'listbox', 'aria-label': label, },
111 onBlock: function (panel, block) {
112 block.autoSize = true;
113 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
118 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
119 var $e = $(e.target);
120 var y = $e.index() + 1;
121 var x = $e.closest('tr').index() + 1;
124 .find('td').removeClass('selected').end()
125 .find('tr:lt(' + String(x) + ')')
126 .children().filter(function () { return $(this).index() < y; })
127 .addClass('selected');
128 }).on('click', 'td', function (e) {
129 var $e = $(e.target);
131 //noinspection JSPotentiallyInvalidConstructorUsage
132 var table = new CKEDITOR.dom.element(
133 $(openerp.qweb.render('website.editor.table', {
134 rows: $e.closest('tr').index() + 1,
135 cols: $e.index() + 1,
138 editor.insertElement(table);
139 setTimeout(function () {
140 //noinspection JSPotentiallyInvalidConstructorUsage
141 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
142 var range = editor.createRange();
143 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
148 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
149 CKEDITOR.ui.fire('ready', this);
155 CKEDITOR.plugins.add('oeref', {
158 init: function (editor) {
159 editor.widgets.add('oeref', {
160 editables: { text: '*' },
162 upcast: function (el) {
163 return el.attributes['data-oe-type']
164 && el.attributes['data-oe-type'] !== 'monetary';
167 editor.widgets.add('monetary', {
168 editables: { text: 'span.oe_currency_value' },
170 upcast: function (el) {
171 return el.attributes['data-oe-type'] === 'monetary';
177 var editor = new website.EditorBar();
178 var $body = $(document.body);
179 editor.prependTo($body).then(function () {
180 if (location.search.indexOf("enable_editor") >= 0) {
184 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
186 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
187 website.EditorBar = openerp.Widget.extend({
188 template: 'website.editorbar',
190 'click button[data-action=edit]': 'edit',
191 'click button[data-action=save]': 'save',
192 'click button[data-action=cancel]': 'cancel',
195 customize_setup: function() {
197 var view_name = $(document.documentElement).data('view-xmlid');
198 var menu = $('#customize-menu');
199 this.$('#customize-menu-button').click(function(event) {
201 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
203 _.each(result, function (item) {
205 menu.append('<li class="dropdown-header">' + item.name + '</li>');
207 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
208 item.id, item.active ? '' : '-empty', item.name));
211 // Adding Static Menus
212 menu.append('<li class="divider"></li><li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
213 menu.append('<li class="divider"></li><li><a data-action="ace" href="#">Advanced view editor</a></li>');
214 self.trigger('rte:customize_menu_ready');
218 menu.on('click', 'a[data-action!=ace]', function (event) {
219 var view_id = $(event.currentTarget).data('view-id');
220 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
222 }).then( function() {
223 window.location.reload();
230 this.saving_mutex = new openerp.Mutex();
232 this.$('#website-top-edit').hide();
233 this.$('#website-top-view').show();
235 $('.dropdown-toggle').dropdown();
236 this.customize_setup();
239 edit: this.$('button[data-action=edit]'),
240 save: this.$('button[data-action=save]'),
241 cancel: this.$('button[data-action=cancel]'),
244 this.rte = new website.RTE(this);
245 this.rte.on('change', this, this.proxy('rte_changed'));
246 this.rte.on('rte:ready', this, function () {
247 self.trigger('rte:ready');
251 this._super.apply(this, arguments),
252 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
256 this.$buttons.edit.prop('disabled', true);
257 this.$('#website-top-view').hide();
258 this.$('#website-top-edit').show();
259 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
261 this.rte.start_edition();
263 rte_changed: function () {
264 this.$buttons.save.prop('disabled', false);
269 observer.disconnect();
270 var editor = this.rte.editor;
271 var root = editor.element.$;
273 // FIXME: select editables then filter by dirty?
274 var defs = this.rte.fetch_editables(root)
276 .removeAttr('contentEditable')
277 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
280 // TODO: Add a queue with concurrency limit in webclient
281 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
282 return self.saving_mutex.exec(function () {
283 return self.saveElement($el)
284 .then(undefined, function (thing, response) {
285 // because ckeditor regenerates all the dom,
286 // we can't just setup the popover here as
287 // everything will be destroyed by the DOM
288 // regeneration. Add markings instead, and
289 // returns a new rejection with all relevant
291 var id = _.uniqueId('carlos_danger_');
292 $el.addClass('oe_dirty oe_carlos_danger');
294 return $.Deferred().reject({
296 error: response.data,
301 return $.when.apply(null, defs).then(function () {
303 }, function (failed) {
304 // If there were errors, re-enable edition
305 self.rte.start_edition(true).then(function () {
306 // jquery's deferred being a pain in the ass
307 if (!_.isArray(failed)) { failed = [failed]; }
309 _(failed).each(function (failure) {
310 $(root).find('.' + failure.id)
311 .removeClass(failure.id)
314 content: failure.error.message,
315 placement: 'auto top',
317 // Force-show popovers so users will notice them.
324 * Saves an RTE content, which always corresponds to a view section (?).
326 saveElement: function ($el) {
327 var markup = $el.prop('outerHTML');
328 return openerp.jsonRpc('/web/dataset/call', 'call', {
331 args: [$el.data('oe-id'), markup,
332 $el.data('oe-xpath') || null,
333 website.get_context()],
336 cancel: function () {
341 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
342 /* ----- RICH TEXT EDITOR ---- */
343 website.RTE = openerp.Widget.extend({
345 id: 'oe_rte_toolbar',
346 className: 'oe_right oe_rte_toolbar',
347 // editor.ui.items -> possible commands &al
348 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
350 init: function (EditorBar) {
351 this.EditorBar = EditorBar;
352 this._super.apply(this, arguments);
356 * In Webkit-based browsers, triple-click will select a paragraph up to
357 * the start of the next "paragraph" including any empty space
358 * inbetween. When said paragraph is removed or altered, it nukes
359 * the empty space and brings part of the content of the next
360 * "paragraph" (which may well be e.g. an image) into the current one,
361 * completely fucking up layouts and breaking snippets.
363 * Try to fuck around with selections on triple-click to attempt to
364 * fix this garbage behavior.
366 * Note: for consistent behavior we may actually want to take over
367 * triple-clicks, in all browsers in order to ensure consistent cross-
368 * platform behavior instead of being at the mercy of rendering engines
369 * & platform selection quirks?
371 webkitSelectionFixer: function (root) {
372 root.addEventListener('click', function (e) {
373 // only webkit seems to have a fucked up behavior, ignore others
374 // FIXME: $.browser goes away in jquery 1.9...
375 if (!$.browser.webkit) { return; }
376 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
377 // The detail attribute indicates the number of times a mouse button has been pressed
378 // we just want the triple click
379 if (e.detail !== 3) { return; }
382 // Get closest block-level element to the triple-clicked
383 // element (using ckeditor's block list because why not)
384 var $closest_block = $(e.target).closest(blocks_selector);
386 // manually set selection range to the content of the
387 // triple-clicked block-level element, to avoid crossing over
388 // between block-level elements
389 document.getSelection().selectAllChildren($closest_block[0]);
392 tableNavigation: function (root) {
394 $(root).on('keydown', function (e) {
396 if (e.which !== 9) { return; }
398 if (self.handleTab(e)) {
404 * Performs whatever operation is necessary on a [TAB] hit, returns
405 * ``true`` if the event's default should be cancelled (if the TAB was
406 * handled by the function)
408 handleTab: function (event) {
409 var forward = !event.shiftKey;
411 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
412 var $cell = $(root).closest('td,th');
414 if (!$cell.length) { return false; }
418 // find cell in same row
419 var row = cell.parentNode;
420 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
422 document.getSelection().selectAllChildren(sibling);
426 // find cell in previous/next row
427 var table = row.parentNode;
428 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
430 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
431 document.getSelection().selectAllChildren(new_cell);
435 // at edge cells, copy word/openoffice behavior: if going backwards
436 // from first cell do nothing, if going forwards from last cell add
439 var row_size = row.cells.length;
440 var new_row = document.createElement('tr');
442 var newcell = document.createElement('td');
444 newcell.textContent = '\u200B';
445 new_row.appendChild(newcell);
447 table.appendChild(new_row);
448 document.getSelection().selectAllChildren(new_row.cells[0]);
454 * Makes the page editable
456 * @param {Boolean} [restart=false] in case the edition was already set
457 * up once and is being re-enabled.
458 * @returns {$.Deferred} deferred indicating when the RTE is ready
460 start_edition: function (restart) {
462 // create a single editor for the whole page
463 var root = document.getElementById('wrapwrap');
465 $(root).on('dragstart', 'img', function (e) {
468 this.webkitSelectionFixer(root);
469 this.tableNavigation(root);
471 var def = $.Deferred();
472 var editor = this.editor = CKEDITOR.inline(root, self._config());
473 editor.on('instanceReady', function () {
474 editor.setReadOnly(false);
475 // ckeditor set root to editable, disable it (only inner
476 // sections are editable)
477 // FIXME: are there cases where the whole editor is editable?
478 editor.editable().setReadOnly(true);
480 self.setup_editables(root);
482 // disable firefox's broken table resizing thing
483 document.execCommand("enableObjectResizing", false, "false");
484 document.execCommand("enableInlineTableEditing", false, "false");
486 self.trigger('rte:ready');
492 setup_editables: function (root) {
493 // selection of editable sub-items was previously in
494 // EditorBar#edit, but for some unknown reason the elements were
495 // apparently removed and recreated (?) at editor initalization,
496 // and observer setup was lost.
498 // setup dirty-marking for each editable element
499 this.fetch_editables(root)
500 .addClass('oe_editable')
504 // only explicitly set contenteditable on view sections,
505 // cke widgets system will do the widgets themselves
506 if ($node.data('oe-model') === 'ir.ui.view') {
507 node.contentEditable = true;
510 observer.observe(node, OBSERVER_CONFIG);
511 $node.one('content_changed', function () {
512 $node.addClass('oe_dirty');
513 self.trigger('change');
518 fetch_editables: function (root) {
519 return $(root).find('[data-oe-model]')
521 .not('.oe_snippet_editor')
522 .filter(function () {
524 // keep view sections and fields which are *not* in
525 // view sections for top-level editables
526 return $this.data('oe-model') === 'ir.ui.view'
527 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
531 _current_editor: function () {
532 return CKEDITOR.currentInstance;
534 _config: function () {
535 // base plugins minus
536 // - magicline (captures mousein/mouseout -> breaks draggable)
537 // - contextmenu & tabletools (disable contextual menu)
538 // - bunch of unused plugins
540 'a11yhelp', 'basicstyles', 'blockquote',
541 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
542 'elementspath', 'enterkey', 'entities', 'filebrowser',
543 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
544 'indentblock', 'indentlist', 'justify',
545 'list', 'pastefromword', 'pastetext', 'preview',
546 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
547 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
552 // Disable auto-generated titles
553 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
555 plugins: plugins.join(','),
557 // FIXME: currently breaks RTE?
558 // Ensure no config file is loaded
561 allowedContent: true,
562 // Don't insert paragraphs around content in e.g. <li>
563 autoParagraph: false,
564 // Don't automatically add or <br> in empty block-level
565 // elements when edition starts
566 fillEmptyBlocks: false,
567 filebrowserImageUploadUrl: "/website/attach",
568 // Support for sharedSpaces in 4.x
569 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
570 // Place toolbar in controlled location
571 sharedSpaces: { top: 'oe_rte_toolbar' },
573 name: 'clipboard', items: [
576 name: 'basicstyles', items: [
577 "Bold", "Italic", "Underline", "Strike", "Subscript",
578 "Superscript", "TextColor", "BGColor", "RemoveFormat"
580 name: 'span', items: [
581 "Link", "Blockquote", "BulletedList",
582 "NumberedList", "Indent", "Outdent"
584 name: 'justify', items: [
585 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
587 name: 'special', items: [
588 "Image", "TableButton"
590 name: 'styles', items: [
594 // styles dropdown in toolbar
596 {name: "Normal", element: 'p'},
597 {name: "Heading 1", element: 'h1'},
598 {name: "Heading 2", element: 'h2'},
599 {name: "Heading 3", element: 'h3'},
600 {name: "Heading 4", element: 'h4'},
601 {name: "Heading 5", element: 'h5'},
602 {name: "Heading 6", element: 'h6'},
603 {name: "Formatted", element: 'pre'},
604 {name: "Address", element: 'address'},
606 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
607 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
608 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
609 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
610 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
611 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
617 website.editor = { };
618 website.editor.Dialog = openerp.Widget.extend({
620 'hidden.bs.modal': 'destroy',
621 'click button.save': 'save',
623 init: function (editor) {
625 this.editor = editor;
628 var sup = this._super();
629 this.$el.modal({backdrop: 'static'});
636 this.$el.modal('hide');
640 website.editor.LinkDialog = website.editor.Dialog.extend({
641 template: 'website.editor.dialog.link',
642 events: _.extend({}, website.editor.Dialog.prototype.events, {
643 'change .url-source': function (e) { this.changed($(e.target)); },
644 'mousedown': function (e) {
645 var $target = $(e.target).closest('.list-group-item');
646 if (!$target.length || $target.hasClass('active')) {
647 // clicked outside groups, or clicked in active groups
651 this.changed($target.find('.url-source'));
653 'click button.remove': 'remove_link',
654 'change input#link-text': function (e) {
655 this.text = $(e.target).val()
658 init: function (editor) {
660 // url -> name mapping for existing pages
661 this.pages = Object.create(null);
666 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
667 this.editor.getSelection().selectElement(element);
669 this.element = element;
671 this.add_removal_button();
675 this.fetch_pages().done(this.proxy('fill_pages')),
676 this.fetch_menus().done(this.proxy('fill_menus')),
678 ).done(this.proxy('bind_data'));
680 add_removal_button: function () {
681 this.$('.modal-footer').prepend(
683 'website.editor.dialog.link.footer-button'));
685 remove_link: function () {
686 var editor = this.editor;
687 // same issue as in make_link
688 setTimeout(function () {
689 editor.removeStyle(new CKEDITOR.style({
691 type: CKEDITOR.STYLE_INLINE,
692 alwaysRemoveElement: true,
698 * Greatly simplified version of CKEDITOR's
699 * plugins.link.dialogs.link.onOk.
701 * @param {String} url
702 * @param {Boolean} [new_window=false]
703 * @param {String} [label=null]
705 make_link: function (url, new_window, label) {
706 var attributes = {href: url, 'data-cke-saved-href': url};
709 attributes['target'] = '_blank';
711 to_remove.push('target');
715 this.element.setAttributes(attributes);
716 this.element.removeAttributes(to_remove);
717 if (this.text) { this.element.setText(this.text); }
719 var selection = this.editor.getSelection();
720 var range = selection.getRanges(true)[0];
722 if (range.collapsed) {
723 //noinspection JSPotentiallyInvalidConstructorUsage
724 var text = new CKEDITOR.dom.text(
725 this.text || label || url);
726 range.insertNode(text);
727 range.selectNodeContents(text);
730 //noinspection JSPotentiallyInvalidConstructorUsage
732 type: CKEDITOR.STYLE_INLINE,
734 attributes: attributes,
735 }).applyToRange(range);
737 // focus dance between RTE & dialog blow up the stack in Safari
738 // and Chrome, so defer select() until dialog has been closed
739 setTimeout(function () {
745 var self = this, _super = this._super.bind(this);
746 var $e = this.$('.list-group-item.active .url-source');
748 if (!val || !$e[0].checkValidity()) {
749 // FIXME: error message
750 $e.closest('.form-group').addClass('has-error');
755 if ($e.hasClass('email-address')) {
756 this.make_link('mailto:' + val, false, val);
757 } else if ($e.hasClass('existing')) {
758 self.make_link(val, false, this.pages[val]);
759 } else if ($e.hasClass('pages')) {
760 // Create the page, get the URL back
761 done = $.get(_.str.sprintf(
762 '/pagenew/%s?noredirect', encodeURI(val)))
763 .then(function (response) {
764 self.make_link(response, false, val);
765 var parent_id = self.$('.add-to-menu').val();
767 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
768 model: 'website.menu',
773 'sequence': 0, // TODO: better tree widget
774 'website_id': website.id,
775 'parent_id': parent_id|0,
778 context: website.get_context()
784 this.make_link(val, this.$('input.window-new').prop('checked'));
788 bind_data: function () {
789 var href = this.element && (this.element.data( 'cke-saved-href')
790 || this.element.getAttribute('href'));
791 if (!href) { return; }
794 if (match = /mailto:(.+)/.exec(href)) {
795 $control = this.$('input.email-address').val(match[1]);
796 } else if (href in this.pages) {
797 $control = this.$('select.existing').val(href);
798 } else if (match = /\/page\/(.+)/.exec(href)) {
799 var actual_href = '/page/website.' + match[1];
800 if (actual_href in this.pages) {
801 $control = this.$('select.existing').val(actual_href);
805 $control = this.$('input.url').val(href);
808 this.changed($control);
810 this.$('input#link-text').val(this.element.getText());
811 this.$('input.window-new').prop(
812 'checked', this.element.getAttribute('target') === '_blank');
814 changed: function ($e) {
815 this.$('.url-source').not($e).val('');
816 $e.closest('.list-group-item')
818 .siblings().removeClass('active')
819 .addBack().removeClass('has-error');
822 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
823 * if the editor is set directly on a link it will thus not work.
825 get_selected_link: function () {
826 return get_selected_link(this.editor);
828 fetch_pages: function () {
829 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
831 method: 'list_pages',
834 context: website.get_context()
838 fill_pages: function (results) {
840 var pages = this.$('select.existing')[0];
841 _(results).each(function (result) {
842 self.pages[result.url] = result.name;
844 pages.options[pages.options.length] =
845 new Option(result.name, result.url);
848 fetch_menus: function () {
849 var context = website.get_context();
850 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
851 model: 'website.menu',
853 args: [[context.website_id]],
859 fill_menus: function (tree) {
861 var menus = this.$('select.add-to-menu')[0];
862 var process_tree = function(node) {
863 var name = (new Array(node.level + 1).join('|-')) + ' ' + node.name;
864 menus.options[menus.options.length] = new Option(name, node.id);
865 node.children.forEach(function (child) {
873 * ImageDialog widget. Lets users change an image, including uploading a
874 * new image in OpenERP or selecting the image style (if supported by
877 * Initialized as usual, but the caller can hook into two events:
879 * @event start({url, style}) called during dialog initialization and
880 * opening, the handler can *set* the ``url``
881 * and ``style`` properties on its parameter
882 * to provide these as default values to the
884 * @event save({url, style}) called during dialog finalization, the handler
885 * is provided with the image url and style
886 * selected by the users (or possibly the ones
887 * originally passed in)
889 website.editor.ImageDialog = website.editor.Dialog.extend({
890 template: 'website.editor.dialog.image',
891 events: _.extend({}, website.editor.Dialog.prototype.events, {
892 'change .url-source': function (e) { this.changed($(e.target)); },
893 'click button.filepicker': function () {
894 this.$('input[type=file]').click();
896 'change input[type=file]': 'file_selection',
897 'change input.url': 'preview_image',
898 'click a[href=#existing]': 'browse_existing',
899 'change select.image-style': 'preview_image',
903 var $options = this.$('.image-style').children();
904 this.image_styles = $options.map(function () { return this.value; }).get();
906 var o = { url: null, style: null, };
907 // avoid typos, prevent addition of new properties to the object
908 Object.preventExtensions(o);
909 this.trigger('start', o);
913 this.$('.image-style').val(o.style);
915 this.set_image(o.url);
918 return this._super();
921 this.trigger('save', {
922 url: this.$('input.url').val(),
923 style: this.$('.image-style').val(),
925 return this._super();
929 * Sets the provided image url as the dialog's value-to-save and
930 * refreshes the preview element to use it.
932 set_image: function (url) {
933 this.$('input.url').val(url);
934 this.preview_image();
937 file_selection: function () {
938 this.$('button.filepicker').removeClass('btn-danger btn-success');
941 var callback = _.uniqueId('func_');
942 this.$('input[name=func]').val(callback);
944 window[callback] = function (url, error) {
945 delete window[callback];
946 self.file_selected(url, error);
948 this.$('form').submit();
950 file_selected: function(url, error) {
951 var $button = this.$('button.filepicker');
953 $button.addClass('btn-danger');
956 $button.addClass('btn-success');
959 preview_image: function () {
960 var image = this.$('input.url').val();
961 if (!image) { return; }
963 this.$('img.image-preview')
965 .removeClass(this.image_styles.join(' '))
966 .addClass(this.$('select.image-style').val());
968 browse_existing: function (e) {
970 new website.editor.ExistingImageDialog(this).appendTo(document.body);
973 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
975 this._super.apply(this, arguments);
977 this.on('start', this, this.proxy('started'));
978 this.on('save', this, this.proxy('saved'));
980 started: function (holder) {
981 var selection = this.editor.getSelection();
982 var el = selection && selection.getSelectedElement();
985 if (el && el.is('img')) {
987 _(this.image_styles).each(function (style) {
988 if (el.hasClass(style)) {
989 holder.style = style;
992 holder.url = el.getAttribute('src');
995 saved: function (data) {
996 var element, editor = this.editor;
997 if (!(element = this.element)) {
998 element = editor.document.createElement('img');
999 element.addClass('img');
1000 // focus event handler interactions between bootstrap (modal)
1001 // and ckeditor (RTE) lead to blowing the stack in Safari and
1002 // Chrome (but not FF) when this is done synchronously =>
1003 // defer insertion so modal has been hidden & destroyed before
1005 setTimeout(function () {
1006 editor.insertElement(element);
1010 var style = data.style;
1011 element.setAttribute('src', data.url);
1012 element.removeAttribute('data-cke-saved-src');
1013 $(element.$).removeClass(this.image_styles.join(' '));
1014 if (style) { element.addClass(style); }
1018 var IMAGES_PER_ROW = 6;
1019 var IMAGES_ROWS = 4;
1020 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1021 template: 'website.editor.dialog.image.existing',
1022 events: _.extend({}, website.editor.Dialog.prototype.events, {
1023 'click .existing-attachments img': 'select_existing',
1024 'click .pager > li': function (e) {
1026 var $target = $(e.currentTarget);
1027 if ($target.hasClass('disabled')) {
1030 this.page += $target.hasClass('previous') ? -1 : 1;
1031 this.display_attachments();
1034 init: function (parent) {
1037 this.parent = parent;
1038 this._super(parent.editor);
1041 start: function () {
1044 this.fetch_existing().then(this.proxy('fetched_existing')));
1047 fetch_existing: function () {
1048 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1049 model: 'ir.attachment',
1050 method: 'search_read',
1053 fields: ['name', 'website_url'],
1054 domain: [['res_model', '=', 'ir.ui.view']],
1056 context: website.get_context(),
1060 fetched_existing: function (records) {
1061 this.records = records;
1062 this.display_attachments();
1064 display_attachments: function () {
1065 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1067 var from = this.page * per_screen;
1068 var records = this.records;
1070 // Create rows of 3 records
1071 var rows = _(records).chain()
1072 .slice(from, from + per_screen)
1073 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1077 this.$('.existing-attachments').replaceWith(
1078 openerp.qweb.render(
1079 'website.editor.dialog.image.existing.content', {rows: rows}));
1081 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1082 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1085 select_existing: function (e) {
1086 var link = $(e.currentTarget).attr('src');
1088 this.parent.set_image(link);
1094 function get_selected_link(editor) {
1095 var sel = editor.getSelection(),
1096 el = sel.getSelectedElement();
1097 if (el && el.is('a')) { return el; }
1099 var range = sel.getRanges(true)[0];
1100 if (!range) { return null; }
1102 range.shrink(CKEDITOR.SHRINK_TEXT);
1103 var commonAncestor = range.getCommonAncestor();
1104 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1105 return element.data('oe-model') === 'ir.ui.view'
1107 if (!viewRoot) { return null; }
1108 // if viewRoot is the first link, don't edit it.
1109 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1110 .contains('a', true);
1114 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1115 var OBSERVER_CONFIG = {
1118 characterData: true,
1120 attributeOldValue: true,
1122 var observer = new website.Observer(function (mutations) {
1123 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1124 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1125 // will not mark dirty on attribute changes (@class, img/@src,
1127 _(mutations).chain()
1128 .filter(function (m) {
1130 case 'attributes': // ignore .cke_focus being added or removed
1131 // if attribute is not a class, can't be .cke_focus change
1132 if (m.attributeName !== 'class') { return true; }
1134 // find out what classes were added or removed
1135 var oldClasses = (m.oldValue || '').split(/\s+/);
1136 var newClasses = m.target.className.split(/\s+/);
1137 var change = _.union(_.difference(oldClasses, newClasses),
1138 _.difference(newClasses, oldClasses));
1139 // ignore mutation if the *only* change is .cke_focus
1140 return change.length !== 1 || change[0] === 'cke_focus';
1142 // <br type="_moz"> appears when focusing RTE in FF, ignore
1143 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1149 var node = m.target;
1150 while (node && !$(node).hasClass('oe_editable')) {
1151 node = node.parentNode;
1153 $(m.target).trigger('node_changed');
1158 .each(function (node) { $(node).trigger('content_changed'); })