4 var website = openerp.website;
7 website.add_template_file('/website/static/src/xml/website.editor.xml');
8 website.dom_ready.done(function () {
9 var is_smartphone = $(document.body)[0].clientWidth < 767;
12 website.ready().then(website.init_editor);
14 // remove padding of fake editor bar
15 document.body.style.padding = 0;
18 $(document).on('click', 'a.js_link2post', function (ev) {
20 website.form(this.pathname, 'POST');
23 $(document).on('submit', '.cke_editable form', function (ev) {
24 // Disable form submition in editable mode
28 $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
29 // Prevent dropdown closing when a contenteditable children is focused
31 && $(ev.target).has(ev.originalEvent.target).length
32 && $(ev.originalEvent.target).is('[contenteditable]')) {
39 * An editing host is an HTML element with @contenteditable=true, or the
40 * child of a document in designMode=on (but that one's not supported)
42 * https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#editing-host
44 function is_editing_host(element) {
45 return element.getAttribute('contentEditable') === 'true';
48 * Checks that both the element's content *and the element itself* are
49 * editable: an editing host is considered non-editable because its content
50 * is editable but its attributes should not be considered editable
52 function is_editable_node(element) {
53 return !(element.data('oe-model') === 'ir.ui.view'
54 || element.data('cke-realelement')
55 || (is_editing_host(element) && element.getAttribute('attributeEditable') !== 'true')
56 || element.isReadOnly());
59 function link_dialog(editor) {
60 return new website.editor.RTELinkDialog(editor).appendTo(document.body);
62 function image_dialog(editor, image) {
63 return new website.editor.RTEImageDialog(editor, image).appendTo(document.body);
66 // only enable editors manually
67 CKEDITOR.disableAutoInline = true;
68 // EDIT ALL THE THINGS
69 CKEDITOR.dtd.$editable = $.extend(
70 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
71 // Disable removal of empty elements on CKEDITOR activation. Empty
72 // elements are used for e.g. support of FontAwesome icons
73 CKEDITOR.dtd.$removeEmpty = {};
75 website.init_editor = function () {
76 CKEDITOR.plugins.add('customdialogs', {
77 // requires: 'link,image',
78 init: function (editor) {
79 editor.on('doubleclick', function (evt) {
80 var element = evt.data.element;
81 if (element.is('img') && is_editable_node(element)) {
82 image_dialog(editor, element);
86 element = get_selected_link(editor) || evt.data.element;
87 if (!(element.is('a') && is_editable_node(element))) {
91 editor.getSelection().selectElement(element);
95 //noinspection JSValidateTypes
96 editor.addCommand('link', {
97 exec: function (editor) {
104 //noinspection JSValidateTypes
105 editor.addCommand('image', {
106 exec: function (editor) {
107 image_dialog(editor);
114 editor.ui.addButton('Link', {
119 editor.ui.addButton('Image', {
122 toolbar: 'insert,10',
125 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
128 CKEDITOR.plugins.add( 'tablebutton', {
129 requires: 'panelbutton,floatpanel',
130 init: function( editor ) {
133 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
136 // use existing 'table' icon
138 modes: { wysiwyg: true },
140 // panel opens in iframe, @css is CSS file <link>-ed within
141 // frame document, @attributes are set on iframe itself.
143 css: '/website/static/src/css/editor.css',
144 attributes: { 'role': 'listbox', 'aria-label': label, },
147 onBlock: function (panel, block) {
148 block.autoSize = true;
149 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
154 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
155 var $e = $(e.target);
156 var y = $e.index() + 1;
157 var x = $e.closest('tr').index() + 1;
160 .find('td').removeClass('selected').end()
161 .find('tr:lt(' + String(x) + ')')
162 .children().filter(function () { return $(this).index() < y; })
163 .addClass('selected');
164 }).on('click', 'td', function (e) {
165 var $e = $(e.target);
167 //noinspection JSPotentiallyInvalidConstructorUsage
168 var table = new CKEDITOR.dom.element(
169 $(openerp.qweb.render('website.editor.table', {
170 rows: $e.closest('tr').index() + 1,
171 cols: $e.index() + 1,
174 editor.insertElement(table);
175 setTimeout(function () {
176 //noinspection JSPotentiallyInvalidConstructorUsage
177 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
178 var range = editor.createRange();
179 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
184 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
185 CKEDITOR.ui.fire('ready', this);
191 CKEDITOR.plugins.add('linkstyle', {
192 requires: 'panelbutton,floatpanel',
193 init: function (editor) {
194 var label = "Link Style";
196 editor.ui.add('LinkStyle', CKEDITOR.UI_PANELBUTTON, {
199 icon: '/website/static/src/img/bglink.png',
200 modes: { wysiwyg: true },
203 css: '/website/static/lib/bootstrap/css/bootstrap.css',
204 attributes: { 'role': 'listbox', 'aria-label': label },
208 'btn-default': _t("Basic"),
209 'btn-primary': _t("Primary"),
210 'btn-success': _t("Success"),
211 'btn-info': _t("Info"),
212 'btn-warning': _t("Warning"),
213 'btn-danger': _t("Danger"),
216 'btn-xs': _t("Extra Small"),
217 'btn-sm': _t("Small"),
219 'btn-lg': _t("Large")
222 onRender: function () {
224 editor.on('selectionChange', function (e) {
225 var path = e.data.path, el;
227 if (!(e = path.contains('a')) || e.isReadOnly()) {
234 // no hook where button is available, so wait
235 // "some time" after render.
236 setTimeout(function () {
240 enable: function () {
241 this.setState(CKEDITOR.TRISTATE_OFF);
243 disable: function () {
244 this.setState(CKEDITOR.TRISTATE_DISABLED);
247 onOpen: function () {
248 var link = get_selected_link(editor);
250 var block = this._.panel._.panel._.blocks[id];
251 var $root = $(block.element.$);
252 $root.find('button').removeClass('active').removeProp('disabled');
254 // enable buttons matching link state
255 for (var type in this.types) {
256 if (!this.types.hasOwnProperty(type)) { continue; }
257 if (!link.hasClass(type)) { continue; }
259 $root.find('button[data-type=types].' + type)
263 for (var size in this.sizes) {
264 if (!this.sizes.hasOwnProperty(size)) { continue; }
265 if (!size || !link.hasClass(size)) { continue; }
267 $root.find('button[data-type=sizes].' + size)
270 if (!found && link.hasClass('btn')) {
271 $root.find('button[data-type="sizes"][data-set-class=""]')
276 onBlock: function (panel, block) {
278 block.autoSize = true;
280 var html = ['<div style="padding: 5px">'];
281 html.push('<div style="white-space: nowrap">');
282 _(this.types).each(function (label, key) {
283 html.push(_.str.sprintf(
284 '<button type="button" class="btn %s" ' +
285 'data-type="types" data-set-class="%s">%s</button>',
289 html.push('<div style="white-space: nowrap; margin: 5px 0; text-align: center">');
290 _(this.sizes).each(function (label, key) {
291 html.push(_.str.sprintf(
292 '<button type="button" class="btn btn-default %s" ' +
293 'data-type="sizes" data-set-class="%s">%s</button>',
297 html.push('<button type="button" class="btn btn-link btn-block" ' +
298 'data-type="reset">Reset</button>');
301 block.element.setHtml(html.join(' '));
302 var $panel = $(block.element.$);
303 $panel.on('click', 'button', function () {
307 clicked: function (button) {
309 editor.fire('saveSnapshot');
311 var $button = $(button),
312 $link = $(get_selected_link(editor).$);
313 if (!$link.hasClass('btn')) {
314 $link.addClass('btn btn-default');
316 switch($button.data('type')) {
318 $link.removeClass('btn')
319 .removeClass(_.keys(this.types).join(' '))
320 .removeClass(_.keys(this.sizes).join(' '));
323 $link.removeClass(_.keys(this.types).join(' '))
324 .addClass($button.data('set-class'));
327 $link.removeClass(_.keys(this.sizes).join(' '))
328 .addClass($button.data('set-class'));
332 editor.fire('saveSnapshot');
339 CKEDITOR.plugins.add('oeref', {
342 init: function (editor) {
343 editor.widgets.add('oeref', {
344 editables: { text: '*' },
347 upcast: function (el) {
348 var matches = el.attributes['data-oe-type'] && el.attributes['data-oe-type'] !== 'monetary';
349 if (!matches) { return false; }
351 if (el.attributes['data-oe-original']) {
352 while (el.children.length) {
353 el.children[0].remove();
355 el.add(new CKEDITOR.htmlParser.text(
356 el.attributes['data-oe-original']
362 editor.widgets.add('monetary', {
363 editables: { text: 'span.oe_currency_value' },
366 upcast: function (el) {
367 return el.attributes['data-oe-type'] === 'monetary';
370 editor.widgets.add('icons', {
374 this.on('edit', function () {
375 new website.editor.FontIconsDialog(editor, this.element.$)
376 .appendTo(document.body);
379 upcast: function (el) {
380 return el.attributes['class']
381 && (/\bfa\b/.test(el.attributes['class']));
387 var editor = new website.EditorBar();
388 var $body = $(document.body);
389 editor.prependTo($body).then(function () {
390 if (location.search.indexOf("enable_editor") >= 0) {
396 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
397 website.EditorBar = openerp.Widget.extend({
398 template: 'website.editorbar',
400 'click button[data-action=edit]': 'edit',
401 'click button[data-action=save]': 'save',
402 'click a[data-action=cancel]': 'cancel',
405 customize_setup: function() {
407 var view_name = $(document.documentElement).data('view-xmlid');
409 this.$('#customize-menu-button').addClass("hidden");
411 var menu = $('#customize-menu');
412 this.$('#customize-menu-button').click(function(event) {
414 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
416 _.each(result, function (item) {
418 menu.append('<li class="dropdown-header">' + item.name + '</li>');
420 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="fa fa%s-square-o"></strong> %s</a></li>',
421 item.id, item.active ? '-check' : '', item.name));
424 // Adding Static Menus
425 menu.append('<li class="divider"></li>');
426 menu.append('<li><a data-action="ace" href="#">HTML Editor</a></li>');
427 menu.append('<li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
428 menu.append('<li><a href="/web#return_label=Website&action=website.action_module_website">Install Apps</a></li>');
429 self.trigger('rte:customize_menu_ready');
433 menu.on('click', 'a[data-action!=ace]', function (event) {
434 var view_id = $(event.currentTarget).data('view-id');
435 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
437 }).then( function() {
438 window.location.reload();
443 // remove placeholder editor bar
444 var fakebar = document.getElementById('website-top-navbar-placeholder');
446 fakebar.parentNode.removeChild(fakebar);
450 this.saving_mutex = new openerp.Mutex();
452 this.$('#website-top-edit').hide();
453 this.$('#website-top-view').show();
455 $('.dropdown-toggle').dropdown();
456 this.customize_setup();
459 edit: this.$('button[data-action=edit]'),
460 save: this.$('button[data-action=save]'),
461 cancel: this.$('button[data-action=cancel]'),
464 this.rte = new website.RTE(this);
465 this.rte.on('change', this, this.proxy('rte_changed'));
466 this.rte.on('rte:ready', this, function () {
467 self.setup_hover_buttons();
468 self.trigger('rte:ready');
472 $(window).on('resize', _.debounce(this.check_height.bind(this), 50));
475 if (website.is_editable_button) {
476 this.$("button[data-action=edit]").removeClass("hidden");
480 this._super.apply(this, arguments),
481 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
486 check_height: function () {
487 var editor_height = this.$el.outerHeight();
488 if (this.get('height') != editor_height) {
489 $(document.body).css('padding-top', editor_height);
490 this.set('height', editor_height);
494 this.$buttons.edit.prop('disabled', true);
495 this.$('#website-top-view').hide();
496 this.$('#website-top-edit').show();
497 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
499 this.rte.start_edition().then(this.check_height.bind(this));
500 this.trigger('rte:called');
502 rte_changed: function () {
503 this.$buttons.save.prop('disabled', false);
508 observer.disconnect();
509 var editor = this.rte.editor;
510 var root = editor.element.$;
512 // FIXME: select editables then filter by dirty?
513 var defs = this.rte.fetch_editables(root)
515 .removeAttr('contentEditable')
516 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
519 // TODO: Add a queue with concurrency limit in webclient
520 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
521 return self.saving_mutex.exec(function () {
522 return self.saveElement($el)
523 .then(undefined, function (thing, response) {
524 // because ckeditor regenerates all the dom,
525 // we can't just setup the popover here as
526 // everything will be destroyed by the DOM
527 // regeneration. Add markings instead, and
528 // returns a new rejection with all relevant
530 var id = _.uniqueId('carlos_danger_');
531 $el.addClass('oe_dirty oe_carlos_danger');
533 return $.Deferred().reject({
535 error: response.data,
540 return $.when.apply(null, defs).then(function () {
542 }, function (failed) {
543 // If there were errors, re-enable edition
544 self.rte.start_edition(true).then(function () {
545 // jquery's deferred being a pain in the ass
546 if (!_.isArray(failed)) { failed = [failed]; }
548 _(failed).each(function (failure) {
549 var html = failure.error.exception_type === "except_osv";
551 var msg = $("<div/>").text(failure.error.message).html();
552 var data = msg.substring(3,msg.length-2).split(/', u'/);
553 failure.error.message = '<b>' + data[0] + '</b><br/>' + data[1];
555 $(root).find('.' + failure.id)
556 .removeClass(failure.id)
560 content: failure.error.message,
561 placement: 'auto top',
563 // Force-show popovers so users will notice them.
570 * Saves an RTE content, which always corresponds to a view section (?).
572 saveElement: function ($el) {
573 var markup = $el.prop('outerHTML');
574 return openerp.jsonRpc('/web/dataset/call', 'call', {
577 args: [$el.data('oe-id'), markup,
578 $el.data('oe-xpath') || null,
579 website.get_context()],
582 cancel: function () {
583 new $.Deferred(function (d) {
584 var $dialog = $(openerp.qweb.render('website.editor.discard')).appendTo(document.body);
585 $dialog.on('click', '.btn-danger', function () {
587 }).on('hidden.bs.modal', function () {
590 d.always(function () {
593 $dialog.modal('show');
594 }).then(function () {
600 * Creates a "hover" button for image and link edition
602 * @param {String} label the button's label
603 * @param {Function} editfn edition function, called when clicking the button
604 * @param {String} [classes] additional classes to set on the button
607 make_hover_button: function (label, editfn, classes) {
608 return $(openerp.qweb.render('website.editor.hoverbutton', {
611 })).hide().appendTo(document.body).click(function (e) {
614 editfn.call(this, e);
618 * For UI clarity, during RTE edition when the user hovers links and
619 * images a small button should appear to make the capability clear,
620 * as not all users think of double-clicking the image or link.
622 setup_hover_buttons: function () {
623 var editor = this.rte.editor;
624 var $link_button = this.make_hover_button(_t("Change"), function () {
625 var sel = new CKEDITOR.dom.element(previous);
626 editor.getSelection().selectElement(sel);
627 if (previous.tagName.toUpperCase() === 'A') {
629 } else if(sel.hasClass('fa')) {
630 new website.editor.FontIconsDialog(editor, previous)
631 .appendTo(document.body);
636 var $image_button = this.make_hover_button(_t("Change"), function () {
637 image_dialog(editor, new CKEDITOR.dom.element(previous));
638 $image_button.hide();
642 // previous is the state of the button-trigger: it's the
643 // currently-ish hovered element which can trigger a button showing.
644 // -ish, because when moving to the button itself ``previous`` is
645 // still set to the element having triggered showing the button.
647 $(editor.element.$).on('mouseover', 'a, img, .fa', function () {
648 // Back from edit button -> ignore
649 if (previous && previous === this) { return; }
651 var selected = new CKEDITOR.dom.element(this);
652 if (!is_editable_node(selected) && !selected.hasClass('fa')) {
657 var $selected = $(this);
658 var position = $selected.offset();
659 if ($selected.is('img')) {
661 // center button on image
662 $image_button.show().offset({
663 top: $selected.outerHeight() / 2
665 - $image_button.outerHeight() / 2,
666 left: $selected.outerWidth() / 2
668 - $image_button.outerWidth() / 2,
671 $image_button.hide();
672 // put button below link, horizontally centered
673 $link_button.show().offset({
674 top: $selected.outerHeight()
676 left: $selected.outerWidth() / 2
678 - $link_button.outerWidth() / 2
681 }).on('mouseleave', 'a, img, .fa', function (e) {
682 var current = document.elementFromPoint(e.clientX, e.clientY);
683 if (current === $link_button[0] || current === $image_button[0]) {
686 $image_button.add($link_button).hide();
692 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
693 /* ----- RICH TEXT EDITOR ---- */
694 website.RTE = openerp.Widget.extend({
696 id: 'oe_rte_toolbar',
697 className: 'oe_right oe_rte_toolbar',
698 // editor.ui.items -> possible commands &al
699 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
701 init: function (EditorBar) {
702 this.EditorBar = EditorBar;
703 this._super.apply(this, arguments);
707 * In Webkit-based browsers, triple-click will select a paragraph up to
708 * the start of the next "paragraph" including any empty space
709 * inbetween. When said paragraph is removed or altered, it nukes
710 * the empty space and brings part of the content of the next
711 * "paragraph" (which may well be e.g. an image) into the current one,
712 * completely fucking up layouts and breaking snippets.
714 * Try to fuck around with selections on triple-click to attempt to
715 * fix this garbage behavior.
717 * Note: for consistent behavior we may actually want to take over
718 * triple-clicks, in all browsers in order to ensure consistent cross-
719 * platform behavior instead of being at the mercy of rendering engines
720 * & platform selection quirks?
722 webkitSelectionFixer: function (root) {
723 root.addEventListener('click', function (e) {
724 // only webkit seems to have a fucked up behavior, ignore others
725 // FIXME: $.browser goes away in jquery 1.9...
726 if (!$.browser.webkit) { return; }
727 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
728 // The detail attribute indicates the number of times a mouse button has been pressed
729 // we just want the triple click
730 if (e.detail !== 3) { return; }
733 // Get closest block-level element to the triple-clicked
734 // element (using ckeditor's block list because why not)
735 var $closest_block = $(e.target).closest(blocks_selector);
737 // manually set selection range to the content of the
738 // triple-clicked block-level element, to avoid crossing over
739 // between block-level elements
740 document.getSelection().selectAllChildren($closest_block[0]);
743 tableNavigation: function (root) {
745 $(root).on('keydown', function (e) {
747 if (e.which !== 9) { return; }
749 if (self.handleTab(e)) {
755 * Performs whatever operation is necessary on a [TAB] hit, returns
756 * ``true`` if the event's default should be cancelled (if the TAB was
757 * handled by the function)
759 handleTab: function (event) {
760 var forward = !event.shiftKey;
762 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
763 var $cell = $(root).closest('td,th');
765 if (!$cell.length) { return false; }
769 // find cell in same row
770 var row = cell.parentNode;
771 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
773 document.getSelection().selectAllChildren(sibling);
777 // find cell in previous/next row
778 var table = row.parentNode;
779 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
781 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
782 document.getSelection().selectAllChildren(new_cell);
786 // at edge cells, copy word/openoffice behavior: if going backwards
787 // from first cell do nothing, if going forwards from last cell add
790 var row_size = row.cells.length;
791 var new_row = document.createElement('tr');
793 var newcell = document.createElement('td');
795 newcell.textContent = '\u200B';
796 new_row.appendChild(newcell);
798 table.appendChild(new_row);
799 document.getSelection().selectAllChildren(new_row.cells[0]);
805 * Makes the page editable
807 * @param {Boolean} [restart=false] in case the edition was already set
808 * up once and is being re-enabled.
809 * @returns {$.Deferred} deferred indicating when the RTE is ready
811 start_edition: function (restart) {
813 // create a single editor for the whole page
814 var root = document.getElementById('wrapwrap');
816 $(root).on('dragstart', 'img', function (e) {
819 this.webkitSelectionFixer(root);
820 this.tableNavigation(root);
822 var def = $.Deferred();
823 var editor = this.editor = CKEDITOR.inline(root, self._config());
824 editor.on('instanceReady', function () {
825 editor.setReadOnly(false);
826 // ckeditor set root to editable, disable it (only inner
827 // sections are editable)
828 // FIXME: are there cases where the whole editor is editable?
829 editor.editable().setReadOnly(true);
831 self.setup_editables(root);
834 // disable firefox's broken table resizing thing
835 document.execCommand("enableObjectResizing", false, "false");
836 document.execCommand("enableInlineTableEditing", false, "false");
840 // detect & setup any CKEDITOR widget within a newly dropped
841 // snippet. There does not seem to be a simple way to do it for
842 // HTML not inserted via ckeditor APIs:
843 // https://dev.ckeditor.com/ticket/11472
845 .off('snippet-dropped')
846 .on('snippet-dropped', function (e, el) {
847 // CKEDITOR data processor extended by widgets plugin
848 // to add wrappers around upcasting elements
849 el.innerHTML = editor.dataProcessor.toHtml(el.innerHTML, {
853 // then repository.initOnAll() handles the conversion
854 // from wrapper to actual widget instance (or something
856 setTimeout(function () {
857 editor.widgets.initOnAll();
861 self.trigger('rte:ready');
867 setup_editables: function (root) {
868 // selection of editable sub-items was previously in
869 // EditorBar#edit, but for some unknown reason the elements were
870 // apparently removed and recreated (?) at editor initalization,
871 // and observer setup was lost.
873 // setup dirty-marking for each editable element
874 this.fetch_editables(root)
875 .addClass('oe_editable')
879 // only explicitly set contenteditable on view sections,
880 // cke widgets system will do the widgets themselves
881 if ($node.data('oe-model') === 'ir.ui.view') {
882 node.contentEditable = true;
885 observer.observe(node, OBSERVER_CONFIG);
886 $node.one('content_changed', function () {
887 $node.addClass('oe_dirty');
888 self.trigger('change');
893 fetch_editables: function (root) {
894 return $(root).find('[data-oe-model]')
896 .not('.oe_snippet_editor')
897 .filter(function () {
899 // keep view sections and fields which are *not* in
900 // view sections for top-level editables
901 return $this.data('oe-model') === 'ir.ui.view'
902 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
906 _current_editor: function () {
907 return CKEDITOR.currentInstance;
909 _config: function () {
910 // base plugins minus
911 // - magicline (captures mousein/mouseout -> breaks draggable)
912 // - contextmenu & tabletools (disable contextual menu)
913 // - bunch of unused plugins
915 'a11yhelp', 'basicstyles', 'blockquote',
916 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
917 'elementspath', /*'enterkey',*/ 'entities', 'filebrowser',
918 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
919 'indentblock', 'indentlist', 'justify',
920 'list', 'pastefromword', 'pastetext', 'preview',
921 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
922 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
927 // Disable auto-generated titles
928 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
930 plugins: plugins.join(','),
932 // FIXME: currently breaks RTE?
933 // Ensure no config file is loaded
936 allowedContent: true,
937 // Don't insert paragraphs around content in e.g. <li>
938 autoParagraph: false,
939 // Don't automatically add or <br> in empty block-level
940 // elements when edition starts
941 fillEmptyBlocks: false,
942 filebrowserImageUploadUrl: "/website/attach",
943 // Support for sharedSpaces in 4.x
944 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,linkstyle',
945 // Place toolbar in controlled location
946 sharedSpaces: { top: 'oe_rte_toolbar' },
948 name: 'basicstyles', items: [
949 "Bold", "Italic", "Underline", "Strike", "Subscript",
950 "Superscript", "TextColor", "BGColor", "RemoveFormat"
952 name: 'span', items: [
953 "Link", "LinkStyle", "Blockquote", "BulletedList",
954 "NumberedList", "Indent", "Outdent"
956 name: 'justify', items: [
957 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
959 name: 'special', items: [
960 "Image", "TableButton"
962 name: 'styles', items: [
966 // styles dropdown in toolbar
968 {name: "Normal", element: 'p'},
969 {name: "Heading 1", element: 'h1'},
970 {name: "Heading 2", element: 'h2'},
971 {name: "Heading 3", element: 'h3'},
972 {name: "Heading 4", element: 'h4'},
973 {name: "Heading 5", element: 'h5'},
974 {name: "Heading 6", element: 'h6'},
975 {name: "Formatted", element: 'pre'},
976 {name: "Address", element: 'address'}
982 website.editor = { };
983 website.editor.Dialog = openerp.Widget.extend({
985 'hidden.bs.modal': 'destroy',
986 'click button.save': 'save',
987 'click button[data-dismiss="modal"]': 'cancel',
989 init: function (editor) {
991 this.editor = editor;
994 var sup = this._super();
995 this.$el.modal({backdrop: 'static'});
1001 cancel: function () {
1003 close: function () {
1004 this.$el.modal('hide');
1008 website.editor.LinkDialog = website.editor.Dialog.extend({
1009 template: 'website.editor.dialog.link',
1010 events: _.extend({}, website.editor.Dialog.prototype.events, {
1011 'change :input.url-source': function (e) { this.changed($(e.target)); },
1012 'mousedown': function (e) {
1013 var $target = $(e.target).closest('.list-group-item');
1014 if (!$target.length || $target.hasClass('active')) {
1015 // clicked outside groups, or clicked in active groups
1019 this.changed($target.find('.url-source').filter(':input'));
1021 'click button.remove': 'remove_link',
1022 'change input#link-text': function (e) {
1023 this.text = $(e.target).val()
1026 init: function (editor) {
1027 this._super(editor);
1029 // Store last-performed request to be able to cancel/abort it.
1030 this.page_exists_req = null;
1031 this.search_pages_req = null;
1033 start: function () {
1035 this.$('#link-page').select2({
1036 minimumInputLength: 1,
1037 placeholder: _t("New or existing page"),
1038 query: function (q) {
1040 self.page_exists(q.term),
1041 self.fetch_pages(q.term)
1042 ).then(function (exists, results) {
1043 var rs = _.map(results, function (r) {
1044 return { id: r.url, text: r.name, };
1050 text: _.str.sprintf(_t("Create page '%s'"), q.term),
1058 q.callback({more: false, results: []});
1062 return this._super().then(this.proxy('bind_data'));
1065 var self = this, _super = this._super.bind(this);
1066 var $e = this.$('.list-group-item.active .url-source').filter(':input');
1068 if (!val || !$e[0].checkValidity()) {
1069 // FIXME: error message
1070 $e.closest('.form-group').addClass('has-error');
1075 var done = $.when();
1076 if ($e.hasClass('email-address')) {
1077 this.make_link('mailto:' + val, false, val);
1078 } else if ($e.hasClass('page')) {
1079 var data = $e.select2('data');
1081 self.make_link(data.id, false, data.text);
1083 // Create the page, get the URL back
1084 done = $.get(_.str.sprintf(
1085 '/website/add/%s?noredirect=1', encodeURI(data.id)))
1086 .then(function (response) {
1087 self.make_link(response, false, data.id);
1091 this.make_link(val, this.$('input.window-new').prop('checked'));
1095 make_link: function (url, new_window, label) {
1097 bind_data: function (text, href, new_window) {
1098 href = href || this.element && (this.element.data( 'cke-saved-href')
1099 || this.element.getAttribute('href'));
1101 if (new_window === undefined) {
1102 new_window = this.element
1103 ? this.element.getAttribute('target') === '_blank'
1106 if (text === undefined) {
1107 text = this.element ? this.element.getText() : '';
1110 this.$('input#link-text').val(text);
1111 this.$('input.window-new').prop('checked', new_window);
1113 if (!href) { return; }
1114 var match, $control;
1115 if ((match = /mailto:(.+)/.exec(href))) {
1116 $control = this.$('input.email-address').val(match[1]);
1119 $control = this.$('input.url').val(href);
1122 this.changed($control);
1124 changed: function ($e) {
1125 this.$('.url-source').filter(':input').not($e).val('')
1126 .filter(function () { return !!$(this).data('select2'); })
1127 .select2('data', null);
1128 $e.closest('.list-group-item')
1130 .siblings().removeClass('active')
1131 .addBack().removeClass('has-error');
1133 call: function (method, args, kwargs) {
1135 var req = method + '_req';
1137 if (this[req]) { this[req].abort(); }
1139 return this[req] = openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1144 }).always(function () {
1148 page_exists: function (term) {
1149 return this.call('page_exists', [null, term], {
1150 context: website.get_context(),
1153 fetch_pages: function (term) {
1154 return this.call('search_pages', [null, term], {
1156 context: website.get_context(),
1160 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
1161 start: function () {
1163 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
1164 this.editor.getSelection().selectElement(element);
1166 this.element = element;
1168 this.add_removal_button();
1171 return this._super();
1173 add_removal_button: function () {
1174 this.$('.modal-footer').prepend(
1175 openerp.qweb.render(
1176 'website.editor.dialog.link.footer-button'));
1178 remove_link: function () {
1179 var editor = this.editor;
1180 // same issue as in make_link
1181 setTimeout(function () {
1182 editor.removeStyle(new CKEDITOR.style({
1184 type: CKEDITOR.STYLE_INLINE,
1185 alwaysRemoveElement: true,
1191 * Greatly simplified version of CKEDITOR's
1192 * plugins.link.dialogs.link.onOk.
1194 * @param {String} url
1195 * @param {Boolean} [new_window=false]
1196 * @param {String} [label=null]
1198 make_link: function (url, new_window, label) {
1199 var attributes = {href: url, 'data-cke-saved-href': url};
1202 attributes['target'] = '_blank';
1204 to_remove.push('target');
1208 this.element.setAttributes(attributes);
1209 this.element.removeAttributes(to_remove);
1210 if (this.text) { this.element.setText(this.text); }
1212 var selection = this.editor.getSelection();
1213 var range = selection.getRanges(true)[0];
1215 if (range.collapsed) {
1216 //noinspection JSPotentiallyInvalidConstructorUsage
1217 var text = new CKEDITOR.dom.text(
1218 this.text || label || url);
1219 range.insertNode(text);
1220 range.selectNodeContents(text);
1223 //noinspection JSPotentiallyInvalidConstructorUsage
1224 new CKEDITOR.style({
1225 type: CKEDITOR.STYLE_INLINE,
1227 attributes: attributes,
1228 }).applyToRange(range);
1230 // focus dance between RTE & dialog blow up the stack in Safari
1231 // and Chrome, so defer select() until dialog has been closed
1232 setTimeout(function () {
1238 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1239 * if the editor is set directly on a link it will thus not work.
1241 get_selected_link: function () {
1242 return get_selected_link(this.editor);
1247 * ImageDialog widget. Lets users change an image, including uploading a
1248 * new image in OpenERP or selecting the image style (if supported by
1251 * Initialized as usual, but the caller can hook into two events:
1253 * @event start({url, style}) called during dialog initialization and
1254 * opening, the handler can *set* the ``url``
1255 * and ``style`` properties on its parameter
1256 * to provide these as default values to the
1258 * @event save({url, style}) called during dialog finalization, the handler
1259 * is provided with the image url and style
1260 * selected by the users (or possibly the ones
1261 * originally passed in)
1263 website.editor.ImageDialog = website.editor.Dialog.extend({
1264 template: 'website.editor.dialog.image',
1265 events: _.extend({}, website.editor.Dialog.prototype.events, {
1266 'change .url-source': function (e) { this.changed($(e.target)); },
1267 'click button.filepicker': function () {
1268 this.$('input[type=file]').click();
1270 'change input[type=file]': 'file_selection',
1271 'change input.url': 'preview_image',
1272 'click a[href=#existing]': 'browse_existing',
1273 'change select.image-style': 'preview_image',
1276 start: function () {
1277 this.$('button.wait').text("Uploading…");
1278 var $options = this.$('.image-style').children();
1279 this.image_styles = $options.map(function () { return this.value; }).get();
1281 var o = { url: null, style: null, };
1282 // avoid typos, prevent addition of new properties to the object
1283 Object.preventExtensions(o);
1284 this.trigger('start', o);
1288 this.$('.image-style').val(o.style);
1290 this.set_image(o.url);
1293 return this._super();
1296 this.trigger('save', {
1297 url: this.$('input.url').val(),
1298 style: this.$('.image-style').val(),
1300 return this._super();
1302 cancel: function () {
1303 this.trigger('cancel');
1307 * Sets the provided image url as the dialog's value-to-save and
1308 * refreshes the preview element to use it.
1310 set_image: function (url, error) {
1311 this.$('input.url').val(
1313 this.$('input.url').val(url);
1314 this.preview_image();
1317 file_selection: function () {
1318 this.$el.addClass('nosave');
1319 this.$('form').removeClass('has-error').find('.help-block').empty();
1320 this.$('button.filepicker').removeClass('btn-danger btn-success');
1323 var callback = _.uniqueId('func_');
1324 this.$('input[name=func]').val(callback);
1326 window[callback] = function (url, error) {
1327 delete window[callback];
1328 self.file_selected(url, error);
1330 this.$('form').submit();
1332 file_selected: function(url, error) {
1333 var $button = this.$('button.filepicker');
1335 $button.addClass('btn-success');
1338 this.$('form').addClass('has-error')
1339 .find('.help-block').text(error);
1340 $button.addClass('btn-danger');
1342 this.set_image(url, error);
1344 preview_image: function () {
1345 var loaded = function () {
1346 this.$el.removeClass('nosave');
1348 var image = this.$('input.url').val();
1349 if (!image) { loaded(); return; }
1351 var $img = this.$('img.image-preview')
1353 .removeClass(this.image_styles.join(' '))
1354 .addClass(this.$('select.image-style').val());
1356 if ($img.prop('complete')) {
1362 browse_existing: function (e) {
1364 this.$('form').removeClass('has-error').find('.help-block').empty();
1365 this.$('button.filepicker').removeClass('btn-danger btn-success');
1366 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1369 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1370 init: function (editor, image) {
1371 this._super(editor);
1373 this.element = image;
1375 this.on('start', this, this.proxy('started'));
1376 this.on('save', this, this.proxy('saved'));
1378 started: function (holder) {
1379 if (!this.element) {
1380 var selection = this.editor.getSelection();
1381 this.element = selection && selection.getSelectedElement();
1384 var el = this.element;
1385 if (!el || !el.is('img')) {
1388 _(this.image_styles).each(function (style) {
1389 if (el.hasClass(style)) {
1390 holder.style = style;
1393 holder.url = el.getAttribute('src');
1395 saved: function (data) {
1396 var element, editor = this.editor;
1397 if (!(element = this.element)) {
1398 element = editor.document.createElement('img');
1399 element.addClass('img');
1400 element.addClass('img-responsive');
1401 // focus event handler interactions between bootstrap (modal)
1402 // and ckeditor (RTE) lead to blowing the stack in Safari and
1403 // Chrome (but not FF) when this is done synchronously =>
1404 // defer insertion so modal has been hidden & destroyed before
1406 setTimeout(function () {
1407 editor.insertElement(element);
1411 var style = data.style;
1412 element.setAttribute('src', data.url);
1413 element.removeAttribute('data-cke-saved-src');
1414 $(element.$).removeClass(this.image_styles.join(' '));
1415 if (style) { element.addClass(style); }
1419 var IMAGES_PER_ROW = 6;
1420 var IMAGES_ROWS = 4;
1421 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1422 template: 'website.editor.dialog.image.existing',
1423 events: _.extend({}, website.editor.Dialog.prototype.events, {
1424 'click .existing-attachments img': 'select_existing',
1425 'click .pager > li': function (e) {
1427 var $target = $(e.currentTarget);
1428 if ($target.hasClass('disabled')) {
1431 this.page += $target.hasClass('previous') ? -1 : 1;
1432 this.display_attachments();
1435 init: function (parent) {
1438 this.parent = parent;
1439 this._super(parent.editor);
1442 start: function () {
1445 this.fetch_existing().then(this.proxy('fetched_existing')));
1448 fetch_existing: function () {
1449 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1450 model: 'ir.attachment',
1451 method: 'search_read',
1454 fields: ['name', 'website_url'],
1455 domain: [['res_model', '=', 'ir.ui.view']],
1457 context: website.get_context(),
1461 fetched_existing: function (records) {
1462 this.records = records;
1463 this.display_attachments();
1465 display_attachments: function () {
1466 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1468 var from = this.page * per_screen;
1469 var records = this.records;
1471 // Create rows of 3 records
1472 var rows = _(records).chain()
1473 .slice(from, from + per_screen)
1474 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1478 this.$('.existing-attachments').replaceWith(
1479 openerp.qweb.render(
1480 'website.editor.dialog.image.existing.content', {rows: rows}));
1482 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1483 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1486 select_existing: function (e) {
1487 var link = $(e.currentTarget).attr('src');
1489 this.parent.set_image(link);
1495 function get_selected_link(editor) {
1496 var sel = editor.getSelection(),
1497 el = sel.getSelectedElement();
1498 if (el && el.is('a')) { return el; }
1500 var range = sel.getRanges(true)[0];
1501 if (!range) { return null; }
1503 range.shrink(CKEDITOR.SHRINK_TEXT);
1504 var commonAncestor = range.getCommonAncestor();
1505 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1506 return element.data('oe-model') === 'ir.ui.view'
1508 if (!viewRoot) { return null; }
1509 // if viewRoot is the first link, don't edit it.
1510 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1511 .contains('a', true);
1514 website.editor.FontIconsDialog = website.editor.Dialog.extend({
1515 template: 'website.editor.dialog.font-icons',
1516 events : _.extend({}, website.editor.Dialog.prototype.events, {
1517 change: 'update_preview',
1518 'click .font-icons-icon': function (e) {
1520 e.stopPropagation();
1522 this.$('#fa-icon').val(e.target.getAttribute('data-id'));
1523 this.update_preview();
1525 'click #fa-preview span': function (e) {
1527 e.stopPropagation();
1529 this.$('#fa-size').val(e.target.getAttribute('data-size'));
1530 this.update_preview();
1532 'input input#icon-search': function () {
1533 var needle = this.$('#icon-search').val();
1534 var icons = this.icons;
1536 icons = _(icons).filter(function (icon) {
1537 return icon.id.substring(3).indexOf(needle) !== -1;
1541 this.$('div.font-icons-icons').html(
1542 openerp.qweb.render(
1543 'website.editor.dialog.font-icons.icons',
1548 // List of FontAwesome icons in 4.0.3, extracted from the cheatsheet.
1549 // Each icon provides the unicode codepoint as ``text`` and the class
1550 // name as ``id`` so the whole thing can be fed directly to select2
1551 // without post-processing and do the right thing (except for the part
1552 // where we still need to implement ``initSelection``)
1553 // TODO: add id/name to the text in order to allow FAYT selection of icons?
1554 icons: [{"text": "\uf000", "id": "fa-glass"}, {"text": "\uf001", "id": "fa-music"}, {"text": "\uf002", "id": "fa-search"}, {"text": "\uf003", "id": "fa-envelope-o"}, {"text": "\uf004", "id": "fa-heart"}, {"text": "\uf005", "id": "fa-star"}, {"text": "\uf006", "id": "fa-star-o"}, {"text": "\uf007", "id": "fa-user"}, {"text": "\uf008", "id": "fa-film"}, {"text": "\uf009", "id": "fa-th-large"}, {"text": "\uf00a", "id": "fa-th"}, {"text": "\uf00b", "id": "fa-th-list"}, {"text": "\uf00c", "id": "fa-check"}, {"text": "\uf00d", "id": "fa-times"}, {"text": "\uf00e", "id": "fa-search-plus"}, {"text": "\uf010", "id": "fa-search-minus"}, {"text": "\uf011", "id": "fa-power-off"}, {"text": "\uf012", "id": "fa-signal"}, {"text": "\uf013", "id": "fa-cog"}, {"text": "\uf014", "id": "fa-trash-o"}, {"text": "\uf015", "id": "fa-home"}, {"text": "\uf016", "id": "fa-file-o"}, {"text": "\uf017", "id": "fa-clock-o"}, {"text": "\uf018", "id": "fa-road"}, {"text": "\uf019", "id": "fa-download"}, {"text": "\uf01a", "id": "fa-arrow-circle-o-down"}, {"text": "\uf01b", "id": "fa-arrow-circle-o-up"}, {"text": "\uf01c", "id": "fa-inbox"}, {"text": "\uf01d", "id": "fa-play-circle-o"}, {"text": "\uf01e", "id": "fa-repeat"}, {"text": "\uf021", "id": "fa-refresh"}, {"text": "\uf022", "id": "fa-list-alt"}, {"text": "\uf023", "id": "fa-lock"}, {"text": "\uf024", "id": "fa-flag"}, {"text": "\uf025", "id": "fa-headphones"}, {"text": "\uf026", "id": "fa-volume-off"}, {"text": "\uf027", "id": "fa-volume-down"}, {"text": "\uf028", "id": "fa-volume-up"}, {"text": "\uf029", "id": "fa-qrcode"}, {"text": "\uf02a", "id": "fa-barcode"}, {"text": "\uf02b", "id": "fa-tag"}, {"text": "\uf02c", "id": "fa-tags"}, {"text": "\uf02d", "id": "fa-book"}, {"text": "\uf02e", "id": "fa-bookmark"}, {"text": "\uf02f", "id": "fa-print"}, {"text": "\uf030", "id": "fa-camera"}, {"text": "\uf031", "id": "fa-font"}, {"text": "\uf032", "id": "fa-bold"}, {"text": "\uf033", "id": "fa-italic"}, {"text": "\uf034", "id": "fa-text-height"}, {"text": "\uf035", "id": "fa-text-width"}, {"text": "\uf036", "id": "fa-align-left"}, {"text": "\uf037", "id": "fa-align-center"}, {"text": "\uf038", "id": "fa-align-right"}, {"text": "\uf039", "id": "fa-align-justify"}, {"text": "\uf03a", "id": "fa-list"}, {"text": "\uf03b", "id": "fa-outdent"}, {"text": "\uf03c", "id": "fa-indent"}, {"text": "\uf03d", "id": "fa-video-camera"}, {"text": "\uf03e", "id": "fa-picture-o"}, {"text": "\uf040", "id": "fa-pencil"}, {"text": "\uf041", "id": "fa-map-marker"}, {"text": "\uf042", "id": "fa-adjust"}, {"text": "\uf043", "id": "fa-tint"}, {"text": "\uf044", "id": "fa-pencil-square-o"}, {"text": "\uf045", "id": "fa-share-square-o"}, {"text": "\uf046", "id": "fa-check-square-o"}, {"text": "\uf047", "id": "fa-arrows"}, {"text": "\uf048", "id": "fa-step-backward"}, {"text": "\uf049", "id": "fa-fast-backward"}, {"text": "\uf04a", "id": "fa-backward"}, {"text": "\uf04b", "id": "fa-play"}, {"text": "\uf04c", "id": "fa-pause"}, {"text": "\uf04d", "id": "fa-stop"}, {"text": "\uf04e", "id": "fa-forward"}, {"text": "\uf050", "id": "fa-fast-forward"}, {"text": "\uf051", "id": "fa-step-forward"}, {"text": "\uf052", "id": "fa-eject"}, {"text": "\uf053", "id": "fa-chevron-left"}, {"text": "\uf054", "id": "fa-chevron-right"}, {"text": "\uf055", "id": "fa-plus-circle"}, {"text": "\uf056", "id": "fa-minus-circle"}, {"text": "\uf057", "id": "fa-times-circle"}, {"text": "\uf058", "id": "fa-check-circle"}, {"text": "\uf059", "id": "fa-question-circle"}, {"text": "\uf05a", "id": "fa-info-circle"}, {"text": "\uf05b", "id": "fa-crosshairs"}, {"text": "\uf05c", "id": "fa-times-circle-o"}, {"text": "\uf05d", "id": "fa-check-circle-o"}, {"text": "\uf05e", "id": "fa-ban"}, {"text": "\uf060", "id": "fa-arrow-left"}, {"text": "\uf061", "id": "fa-arrow-right"}, {"text": "\uf062", "id": "fa-arrow-up"}, {"text": "\uf063", "id": "fa-arrow-down"}, {"text": "\uf064", "id": "fa-share"}, {"text": "\uf065", "id": "fa-expand"}, {"text": "\uf066", "id": "fa-compress"}, {"text": "\uf067", "id": "fa-plus"}, {"text": "\uf068", "id": "fa-minus"}, {"text": "\uf069", "id": "fa-asterisk"}, {"text": "\uf06a", "id": "fa-exclamation-circle"}, {"text": "\uf06b", "id": "fa-gift"}, {"text": "\uf06c", "id": "fa-leaf"}, {"text": "\uf06d", "id": "fa-fire"}, {"text": "\uf06e", "id": "fa-eye"}, {"text": "\uf070", "id": "fa-eye-slash"}, {"text": "\uf071", "id": "fa-exclamation-triangle"}, {"text": "\uf072", "id": "fa-plane"}, {"text": "\uf073", "id": "fa-calendar"}, {"text": "\uf074", "id": "fa-random"}, {"text": "\uf075", "id": "fa-comment"}, {"text": "\uf076", "id": "fa-magnet"}, {"text": "\uf077", "id": "fa-chevron-up"}, {"text": "\uf078", "id": "fa-chevron-down"}, {"text": "\uf079", "id": "fa-retweet"}, {"text": "\uf07a", "id": "fa-shopping-cart"}, {"text": "\uf07b", "id": "fa-folder"}, {"text": "\uf07c", "id": "fa-folder-open"}, {"text": "\uf07d", "id": "fa-arrows-v"}, {"text": "\uf07e", "id": "fa-arrows-h"}, {"text": "\uf080", "id": "fa-bar-chart-o"}, {"text": "\uf081", "id": "fa-twitter-square"}, {"text": "\uf082", "id": "fa-facebook-square"}, {"text": "\uf083", "id": "fa-camera-retro"}, {"text": "\uf084", "id": "fa-key"}, {"text": "\uf085", "id": "fa-cogs"}, {"text": "\uf086", "id": "fa-comments"}, {"text": "\uf087", "id": "fa-thumbs-o-up"}, {"text": "\uf088", "id": "fa-thumbs-o-down"}, {"text": "\uf089", "id": "fa-star-half"}, {"text": "\uf08a", "id": "fa-heart-o"}, {"text": "\uf08b", "id": "fa-sign-out"}, {"text": "\uf08c", "id": "fa-linkedin-square"}, {"text": "\uf08d", "id": "fa-thumb-tack"}, {"text": "\uf08e", "id": "fa-external-link"}, {"text": "\uf090", "id": "fa-sign-in"}, {"text": "\uf091", "id": "fa-trophy"}, {"text": "\uf092", "id": "fa-github-square"}, {"text": "\uf093", "id": "fa-upload"}, {"text": "\uf094", "id": "fa-lemon-o"}, {"text": "\uf095", "id": "fa-phone"}, {"text": "\uf096", "id": "fa-square-o"}, {"text": "\uf097", "id": "fa-bookmark-o"}, {"text": "\uf098", "id": "fa-phone-square"}, {"text": "\uf099", "id": "fa-twitter"}, {"text": "\uf09a", "id": "fa-facebook"}, {"text": "\uf09b", "id": "fa-github"}, {"text": "\uf09c", "id": "fa-unlock"}, {"text": "\uf09d", "id": "fa-credit-card"}, {"text": "\uf09e", "id": "fa-rss"}, {"text": "\uf0a0", "id": "fa-hdd-o"}, {"text": "\uf0a1", "id": "fa-bullhorn"}, {"text": "\uf0f3", "id": "fa-bell"}, {"text": "\uf0a3", "id": "fa-certificate"}, {"text": "\uf0a4", "id": "fa-hand-o-right"}, {"text": "\uf0a5", "id": "fa-hand-o-left"}, {"text": "\uf0a6", "id": "fa-hand-o-up"}, {"text": "\uf0a7", "id": "fa-hand-o-down"}, {"text": "\uf0a8", "id": "fa-arrow-circle-left"}, {"text": "\uf0a9", "id": "fa-arrow-circle-right"}, {"text": "\uf0aa", "id": "fa-arrow-circle-up"}, {"text": "\uf0ab", "id": "fa-arrow-circle-down"}, {"text": "\uf0ac", "id": "fa-globe"}, {"text": "\uf0ad", "id": "fa-wrench"}, {"text": "\uf0ae", "id": "fa-tasks"}, {"text": "\uf0b0", "id": "fa-filter"}, {"text": "\uf0b1", "id": "fa-briefcase"}, {"text": "\uf0b2", "id": "fa-arrows-alt"}, {"text": "\uf0c0", "id": "fa-users"}, {"text": "\uf0c1", "id": "fa-link"}, {"text": "\uf0c2", "id": "fa-cloud"}, {"text": "\uf0c3", "id": "fa-flask"}, {"text": "\uf0c4", "id": "fa-scissors"}, {"text": "\uf0c5", "id": "fa-files-o"}, {"text": "\uf0c6", "id": "fa-paperclip"}, {"text": "\uf0c7", "id": "fa-floppy-o"}, {"text": "\uf0c8", "id": "fa-square"}, {"text": "\uf0c9", "id": "fa-bars"}, {"text": "\uf0ca", "id": "fa-list-ul"}, {"text": "\uf0cb", "id": "fa-list-ol"}, {"text": "\uf0cc", "id": "fa-strikethrough"}, {"text": "\uf0cd", "id": "fa-underline"}, {"text": "\uf0ce", "id": "fa-table"}, {"text": "\uf0d0", "id": "fa-magic"}, {"text": "\uf0d1", "id": "fa-truck"}, {"text": "\uf0d2", "id": "fa-pinterest"}, {"text": "\uf0d3", "id": "fa-pinterest-square"}, {"text": "\uf0d4", "id": "fa-google-plus-square"}, {"text": "\uf0d5", "id": "fa-google-plus"}, {"text": "\uf0d6", "id": "fa-money"}, {"text": "\uf0d7", "id": "fa-caret-down"}, {"text": "\uf0d8", "id": "fa-caret-up"}, {"text": "\uf0d9", "id": "fa-caret-left"}, {"text": "\uf0da", "id": "fa-caret-right"}, {"text": "\uf0db", "id": "fa-columns"}, {"text": "\uf0dc", "id": "fa-sort"}, {"text": "\uf0dd", "id": "fa-sort-asc"}, {"text": "\uf0de", "id": "fa-sort-desc"}, {"text": "\uf0e0", "id": "fa-envelope"}, {"text": "\uf0e1", "id": "fa-linkedin"}, {"text": "\uf0e2", "id": "fa-undo"}, {"text": "\uf0e3", "id": "fa-gavel"}, {"text": "\uf0e4", "id": "fa-tachometer"}, {"text": "\uf0e5", "id": "fa-comment-o"}, {"text": "\uf0e6", "id": "fa-comments-o"}, {"text": "\uf0e7", "id": "fa-bolt"}, {"text": "\uf0e8", "id": "fa-sitemap"}, {"text": "\uf0e9", "id": "fa-umbrella"}, {"text": "\uf0ea", "id": "fa-clipboard"}, {"text": "\uf0eb", "id": "fa-lightbulb-o"}, {"text": "\uf0ec", "id": "fa-exchange"}, {"text": "\uf0ed", "id": "fa-cloud-download"}, {"text": "\uf0ee", "id": "fa-cloud-upload"}, {"text": "\uf0f0", "id": "fa-user-md"}, {"text": "\uf0f1", "id": "fa-stethoscope"}, {"text": "\uf0f2", "id": "fa-suitcase"}, {"text": "\uf0a2", "id": "fa-bell-o"}, {"text": "\uf0f4", "id": "fa-coffee"}, {"text": "\uf0f5", "id": "fa-cutlery"}, {"text": "\uf0f6", "id": "fa-file-text-o"}, {"text": "\uf0f7", "id": "fa-building-o"}, {"text": "\uf0f8", "id": "fa-hospital-o"}, {"text": "\uf0f9", "id": "fa-ambulance"}, {"text": "\uf0fa", "id": "fa-medkit"}, {"text": "\uf0fb", "id": "fa-fighter-jet"}, {"text": "\uf0fc", "id": "fa-beer"}, {"text": "\uf0fd", "id": "fa-h-square"}, {"text": "\uf0fe", "id": "fa-plus-square"}, {"text": "\uf100", "id": "fa-angle-double-left"}, {"text": "\uf101", "id": "fa-angle-double-right"}, {"text": "\uf102", "id": "fa-angle-double-up"}, {"text": "\uf103", "id": "fa-angle-double-down"}, {"text": "\uf104", "id": "fa-angle-left"}, {"text": "\uf105", "id": "fa-angle-right"}, {"text": "\uf106", "id": "fa-angle-up"}, {"text": "\uf107", "id": "fa-angle-down"}, {"text": "\uf108", "id": "fa-desktop"}, {"text": "\uf109", "id": "fa-laptop"}, {"text": "\uf10a", "id": "fa-tablet"}, {"text": "\uf10b", "id": "fa-mobile"}, {"text": "\uf10c", "id": "fa-circle-o"}, {"text": "\uf10d", "id": "fa-quote-left"}, {"text": "\uf10e", "id": "fa-quote-right"}, {"text": "\uf110", "id": "fa-spinner"}, {"text": "\uf111", "id": "fa-circle"}, {"text": "\uf112", "id": "fa-reply"}, {"text": "\uf113", "id": "fa-github-alt"}, {"text": "\uf114", "id": "fa-folder-o"}, {"text": "\uf115", "id": "fa-folder-open-o"}, {"text": "\uf118", "id": "fa-smile-o"}, {"text": "\uf119", "id": "fa-frown-o"}, {"text": "\uf11a", "id": "fa-meh-o"}, {"text": "\uf11b", "id": "fa-gamepad"}, {"text": "\uf11c", "id": "fa-keyboard-o"}, {"text": "\uf11d", "id": "fa-flag-o"}, {"text": "\uf11e", "id": "fa-flag-checkered"}, {"text": "\uf120", "id": "fa-terminal"}, {"text": "\uf121", "id": "fa-code"}, {"text": "\uf122", "id": "fa-reply-all"}, {"text": "\uf122", "id": "fa-mail-reply-all"}, {"text": "\uf123", "id": "fa-star-half-o"}, {"text": "\uf124", "id": "fa-location-arrow"}, {"text": "\uf125", "id": "fa-crop"}, {"text": "\uf126", "id": "fa-code-fork"}, {"text": "\uf127", "id": "fa-chain-broken"}, {"text": "\uf128", "id": "fa-question"}, {"text": "\uf129", "id": "fa-info"}, {"text": "\uf12a", "id": "fa-exclamation"}, {"text": "\uf12b", "id": "fa-superscript"}, {"text": "\uf12c", "id": "fa-subscript"}, {"text": "\uf12d", "id": "fa-eraser"}, {"text": "\uf12e", "id": "fa-puzzle-piece"}, {"text": "\uf130", "id": "fa-microphone"}, {"text": "\uf131", "id": "fa-microphone-slash"}, {"text": "\uf132", "id": "fa-shield"}, {"text": "\uf133", "id": "fa-calendar-o"}, {"text": "\uf134", "id": "fa-fire-extinguisher"}, {"text": "\uf135", "id": "fa-rocket"}, {"text": "\uf136", "id": "fa-maxcdn"}, {"text": "\uf137", "id": "fa-chevron-circle-left"}, {"text": "\uf138", "id": "fa-chevron-circle-right"}, {"text": "\uf139", "id": "fa-chevron-circle-up"}, {"text": "\uf13a", "id": "fa-chevron-circle-down"}, {"text": "\uf13b", "id": "fa-html5"}, {"text": "\uf13c", "id": "fa-css3"}, {"text": "\uf13d", "id": "fa-anchor"}, {"text": "\uf13e", "id": "fa-unlock-alt"}, {"text": "\uf140", "id": "fa-bullseye"}, {"text": "\uf141", "id": "fa-ellipsis-h"}, {"text": "\uf142", "id": "fa-ellipsis-v"}, {"text": "\uf143", "id": "fa-rss-square"}, {"text": "\uf144", "id": "fa-play-circle"}, {"text": "\uf145", "id": "fa-ticket"}, {"text": "\uf146", "id": "fa-minus-square"}, {"text": "\uf147", "id": "fa-minus-square-o"}, {"text": "\uf148", "id": "fa-level-up"}, {"text": "\uf149", "id": "fa-level-down"}, {"text": "\uf14a", "id": "fa-check-square"}, {"text": "\uf14b", "id": "fa-pencil-square"}, {"text": "\uf14c", "id": "fa-external-link-square"}, {"text": "\uf14d", "id": "fa-share-square"}, {"text": "\uf14e", "id": "fa-compass"}, {"text": "\uf150", "id": "fa-caret-square-o-down"}, {"text": "\uf151", "id": "fa-caret-square-o-up"}, {"text": "\uf152", "id": "fa-caret-square-o-right"}, {"text": "\uf153", "id": "fa-eur"}, {"text": "\uf154", "id": "fa-gbp"}, {"text": "\uf155", "id": "fa-usd"}, {"text": "\uf156", "id": "fa-inr"}, {"text": "\uf157", "id": "fa-jpy"}, {"text": "\uf158", "id": "fa-rub"}, {"text": "\uf159", "id": "fa-krw"}, {"text": "\uf15a", "id": "fa-btc"}, {"text": "\uf15b", "id": "fa-file"}, {"text": "\uf15c", "id": "fa-file-text"}, {"text": "\uf15d", "id": "fa-sort-alpha-asc"}, {"text": "\uf15e", "id": "fa-sort-alpha-desc"}, {"text": "\uf160", "id": "fa-sort-amount-asc"}, {"text": "\uf161", "id": "fa-sort-amount-desc"}, {"text": "\uf162", "id": "fa-sort-numeric-asc"}, {"text": "\uf163", "id": "fa-sort-numeric-desc"}, {"text": "\uf164", "id": "fa-thumbs-up"}, {"text": "\uf165", "id": "fa-thumbs-down"}, {"text": "\uf166", "id": "fa-youtube-square"}, {"text": "\uf167", "id": "fa-youtube"}, {"text": "\uf168", "id": "fa-xing"}, {"text": "\uf169", "id": "fa-xing-square"}, {"text": "\uf16a", "id": "fa-youtube-play"}, {"text": "\uf16b", "id": "fa-dropbox"}, {"text": "\uf16c", "id": "fa-stack-overflow"}, {"text": "\uf16d", "id": "fa-instagram"}, {"text": "\uf16e", "id": "fa-flickr"}, {"text": "\uf170", "id": "fa-adn"}, {"text": "\uf171", "id": "fa-bitbucket"}, {"text": "\uf172", "id": "fa-bitbucket-square"}, {"text": "\uf173", "id": "fa-tumblr"}, {"text": "\uf174", "id": "fa-tumblr-square"}, {"text": "\uf175", "id": "fa-long-arrow-down"}, {"text": "\uf176", "id": "fa-long-arrow-up"}, {"text": "\uf177", "id": "fa-long-arrow-left"}, {"text": "\uf178", "id": "fa-long-arrow-right"}, {"text": "\uf179", "id": "fa-apple"}, {"text": "\uf17a", "id": "fa-windows"}, {"text": "\uf17b", "id": "fa-android"}, {"text": "\uf17c", "id": "fa-linux"}, {"text": "\uf17d", "id": "fa-dribbble"}, {"text": "\uf17e", "id": "fa-skype"}, {"text": "\uf180", "id": "fa-foursquare"}, {"text": "\uf181", "id": "fa-trello"}, {"text": "\uf182", "id": "fa-female"}, {"text": "\uf183", "id": "fa-male"}, {"text": "\uf184", "id": "fa-gittip"}, {"text": "\uf185", "id": "fa-sun-o"}, {"text": "\uf186", "id": "fa-moon-o"}, {"text": "\uf187", "id": "fa-archive"}, {"text": "\uf188", "id": "fa-bug"}, {"text": "\uf189", "id": "fa-vk"}, {"text": "\uf18a", "id": "fa-weibo"}, {"text": "\uf18b", "id": "fa-renren"}, {"text": "\uf18c", "id": "fa-pagelines"}, {"text": "\uf18d", "id": "fa-stack-exchange"}, {"text": "\uf18e", "id": "fa-arrow-circle-o-right"}, {"text": "\uf190", "id": "fa-arrow-circle-o-left"}, {"text": "\uf191", "id": "fa-caret-square-o-left"}, {"text": "\uf192", "id": "fa-dot-circle-o"}, {"text": "\uf193", "id": "fa-wheelchair"}, {"text": "\uf194", "id": "fa-vimeo-square"}, {"text": "\uf195", "id": "fa-try"}, {"text": "\uf196", "id": "fa-plus-square-o"}],
1555 init: function (editor, element) {
1556 this._super(editor);
1557 this.element = element;
1560 * Initializes select2: in Chrome and Safari, <select> font apparently
1561 * isn't customizable (?) and the fontawesome glyphs fail to appear.
1563 start: function () {
1564 return this._super().then(this.proxy('load_data'));
1567 * Removes existing FontAwesome classes on the bound element, and sets
1568 * all the new ones if necessary.
1571 var classes = this.element.className.split(/\s+/);
1572 var non_fa_classes = _.reject(classes, function (cls) {
1573 return cls === 'fa' || /^fa-/.test(cls);
1575 var final_classes = non_fa_classes.concat(this.get_fa_classes());
1576 this.element.className = final_classes.join(' ');
1580 * Looks up the various FontAwesome classes on the bound element and
1581 * sets the corresponding template/form elements to the right state.
1582 * If multiple classes of the same category are present on an element
1583 * (e.g. fa-lg and fa-3x) the last one occurring will be selected,
1584 * which may not match the visual look of the element.
1586 load_data: function () {
1587 var classes = this.element.className.split(/\s+/);
1588 for (var i = 0; i < classes.length; i++) {
1589 var cls = classes[i];
1591 case 'fa-2x':case 'fa-3x':case 'fa-4x':case 'fa-5x':
1593 this.$('#fa-size').val(cls);
1596 case 'fa-rotate-90':case 'fa-rotate-180':case 'fa-rotate-270':
1597 case 'fa-flip-horizontal':case 'fa-rotate-vertical':
1598 this.$('#fa-rotation').val(cls);
1603 this.$('#fa-border').prop('checked', true);
1606 if (!/^fa-/.test(cls)) { continue; }
1607 this.$('#fa-icon').val(cls);
1610 this.update_preview();
1613 * Serializes the dialog to an array of FontAwesome classes. Includes
1616 get_fa_classes: function () {
1619 this.$('#fa-icon').val(),
1620 this.$('#fa-size').val(),
1621 this.$('#fa-rotation').val(),
1622 this.$('#fa-border').prop('checked') ? 'fa-border' : ''
1625 update_preview: function () {
1626 var $preview = this.$('#fa-preview').empty();
1627 var sizes = ['', 'fa-2x', 'fa-3x', 'fa-4x', 'fa-5x'];
1628 var classes = this.get_fa_classes();
1629 var no_sizes = _.difference(classes, sizes).join(' ');
1630 var selected = false;
1631 for (var i = sizes.length - 1; i >= 0; i--) {
1632 var size = sizes[i];
1634 var $p = $('<span>')
1635 .attr('data-size', size)
1637 .addClass(no_sizes);
1638 if ((size && _.contains(classes, size)) || (!size && !selected)) {
1639 $p.addClass('font-icons-selected');
1642 $preview.prepend($p);
1647 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1648 var OBSERVER_CONFIG = {
1651 characterData: true,
1653 attributeOldValue: true,
1655 var observer = new website.Observer(function (mutations) {
1656 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1657 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1658 // will not mark dirty on attribute changes (@class, img/@src,
1660 _(mutations).chain()
1661 .filter(function (m) {
1662 // ignore any change related to mundane image-edit-button
1663 if (m.target && m.target.className
1664 && m.target.className.indexOf('image-edit-button') !== -1) {
1668 case 'attributes': // ignore .cke_focus being added or removed
1669 // if attribute is not a class, can't be .cke_focus change
1670 if (m.attributeName !== 'class') { return true; }
1672 // find out what classes were added or removed
1673 var oldClasses = (m.oldValue || '').split(/\s+/);
1674 var newClasses = m.target.className.split(/\s+/);
1675 var change = _.union(_.difference(oldClasses, newClasses),
1676 _.difference(newClasses, oldClasses));
1677 // ignore mutation if the *only* change is .cke_focus
1678 return change.length !== 1 || change[0] === 'cke_focus';
1680 // Remove ignorable nodes from addedNodes or removedNodes,
1681 // if either set remains non-empty it's considered to be an
1682 // impactful change. Otherwise it's ignored.
1683 return !!remove_mundane_nodes(m.addedNodes).length ||
1684 !!remove_mundane_nodes(m.removedNodes).length;
1690 var node = m.target;
1691 while (node && !$(node).hasClass('oe_editable')) {
1692 node = node.parentNode;
1698 .each(function (node) { $(node).trigger('content_changed'); })
1700 function remove_mundane_nodes(nodes) {
1701 if (!nodes || !nodes.length) { return []; }
1704 for(var i=0; i<nodes.length; ++i) {
1705 var node = nodes[i];
1706 if (node.nodeType === document.ELEMENT_NODE) {
1707 if (node.nodeName === 'BR' && node.getAttribute('type') === '_moz') {
1708 // <br type="_moz"> appears when focusing RTE in FF, ignore