');
block.element.setHtml(html.join(' '));
var $panel = $(block.element.$);
$panel.on('click', 'button', function () {
self.clicked(this);
});
},
clicked: function (button) {
editor.focus();
editor.fire('saveSnapshot');
var $button = $(button),
$link = $(get_selected_link(editor).$);
if (!$link.hasClass('btn')) {
$link.addClass('btn btn-default');
}
switch($button.data('type')) {
case 'reset':
$link.removeClass('btn')
.removeClass(_.keys(this.types).join(' '))
.removeClass(_.keys(this.sizes).join(' '));
break;
case 'types':
$link.removeClass(_.keys(this.types).join(' '))
.addClass($button.data('set-class'));
break;
case 'sizes':
$link.removeClass(_.keys(this.sizes).join(' '))
.addClass($button.data('set-class'));
}
this._.panel.hide();
editor.fire('saveSnapshot');
},
});
}
});
CKEDITOR.plugins.add('oeref', {
requires: 'widget',
init: function (editor) {
var specials = {
// Can't find the correct ACL rule to only allow img tags
image: { content: '*' },
html: { text: '*' },
monetary: {
text: {
selector: 'span.oe_currency_value',
allowedContent: { }
}
}
};
_(specials).each(function (editable, type) {
editor.widgets.add(type, {
draggable: false,
editables: editable,
upcast: function (el) {
return el.attributes['data-oe-type'] === type;
}
});
});
editor.widgets.add('oeref', {
draggable: false,
editables: {
text: {
selector: '*',
allowedContent: { }
},
},
upcast: function (el) {
var type = el.attributes['data-oe-type'];
if (!type || (type in specials)) {
return false;
}
if (el.attributes['data-oe-original']) {
while (el.children.length) {
el.children[0].remove();
}
el.add(new CKEDITOR.htmlParser.text(
el.attributes['data-oe-original']
));
}
return true;
}
});
editor.widgets.add('icons', {
draggable: false,
init: function () {
this.on('edit', function () {
new website.editor.MediaDialog(editor, this.element)
.appendTo(document.body);
});
},
upcast: function (el) {
return el.hasClass('fa')
// ignore ir.ui.view (other data-oe-model should
// already have been matched by oeref and
// monetary?
&& !el.attributes['data-oe-model'];
}
});
}
});
var editor = new website.EditorBar();
var $body = $(document.body);
editor.prependTo($body).then(function () {
if (location.search.indexOf("enable_editor") >= 0) {
editor.edit();
}
});
website.editor_bar = editor;
};
/* ----- TOP EDITOR BAR FOR ADMIN ---- */
website.EditorBar = openerp.Widget.extend({
template: 'website.editorbar',
events: {
'click button[data-action=save]': 'save',
'click a[data-action=cancel]': 'cancel',
},
start: function() {
var self = this;
this.saving_mutex = new openerp.Mutex();
this.$buttons = {
edit: this.$el.parents().find('button[data-action=edit]'),
save: this.$('button[data-action=save]'),
cancel: this.$('button[data-action=cancel]'),
};
this.$('#website-top-edit').hide();
this.$('#website-top-view').show();
this.$buttons.edit.show();
var $edit_button = this.$buttons.edit
.prop('disabled', website.no_editor);
if (website.no_editor) {
var help_text = $(document.documentElement).data('editable-no-editor');
$edit_button.parent()
// help must be set on form above button because it does
// not appear on disabled button
.attr('title', help_text);
}
$('.dropdown-toggle').dropdown();
this.$buttons.edit.click(function(ev) {
self.edit();
});
this.rte = new website.RTE(this);
this.rte.on('change', this, this.proxy('rte_changed'));
this.rte.on('rte:ready', this, function () {
self.setup_hover_buttons();
self.trigger('rte:ready');
});
this.rte.appendTo(this.$('#website-top-edit .nav.js_editor_placeholder'));
return this._super.apply(this, arguments);
},
edit: function () {
this.$buttons.edit.prop('disabled', true);
this.$('#website-top-view').hide();
this.$el.show();
this.$('#website-top-edit').show();
$('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
this.rte.start_edition();
this.trigger('rte:called');
},
rte_changed: function () {
this.$buttons.save.prop('disabled', false);
},
save: function () {
var self = this;
observer.disconnect();
var editor = this.rte.editor;
var root = editor.element && editor.element.$;
editor.destroy();
// FIXME: select editables then filter by dirty?
var defs = this.rte.fetch_editables(root)
.filter('.oe_dirty')
.removeAttr('contentEditable')
.removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
.map(function () {
var $el = $(this);
// TODO: Add a queue with concurrency limit in webclient
// https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
return self.saving_mutex.exec(function () {
return self.saveElement($el)
.then(undefined, function (thing, response) {
// because ckeditor regenerates all the dom,
// we can't just setup the popover here as
// everything will be destroyed by the DOM
// regeneration. Add markings instead, and
// returns a new rejection with all relevant
// info
var id = _.uniqueId('carlos_danger_');
$el.addClass('oe_dirty oe_carlos_danger');
$el.addClass(id);
return $.Deferred().reject({
id: id,
error: response.data,
});
});
});
}).get();
return $.when.apply(null, defs).then(function () {
website.reload();
}, function (failed) {
// If there were errors, re-enable edition
self.rte.start_edition(true).then(function () {
// jquery's deferred being a pain in the ass
if (!_.isArray(failed)) { failed = [failed]; }
_(failed).each(function (failure) {
var html = failure.error.exception_type === "except_osv";
if (html) {
var msg = $("").text(failure.error.message).html();
var data = msg.substring(3,msg.length-2).split(/', u'/);
failure.error.message = '' + data[0] + ' ' + data[1];
}
$(root).find('.' + failure.id)
.removeClass(failure.id)
.popover({
html: html,
trigger: 'hover',
content: failure.error.message,
placement: 'auto top',
})
// Force-show popovers so users will notice them.
.popover('show');
});
});
});
},
/**
* Saves an RTE content, which always corresponds to a view section (?).
*/
saveElement: function ($el) {
var markup = $el.prop('outerHTML');
return openerp.jsonRpc('/web/dataset/call', 'call', {
model: 'ir.ui.view',
method: 'save',
args: [$el.data('oe-id'), markup,
$el.data('oe-xpath') || null,
website.get_context()],
});
},
cancel: function () {
new $.Deferred(function (d) {
var $dialog = $(openerp.qweb.render('website.editor.discard')).appendTo(document.body);
$dialog.on('click', '.btn-danger', function () {
d.resolve();
}).on('hidden.bs.modal', function () {
d.reject();
});
d.always(function () {
$dialog.remove();
});
$dialog.modal('show');
}).then(function () {
website.reload();
})
},
/**
* Creates a "hover" button for link edition
*
* @param {Function} editfn edition function, called when clicking the button
* @returns {jQuery}
*/
make_hover_button_link: function (editfn) {
return $(openerp.qweb.render('website.editor.hoverbutton.link', {}))
.hide()
.click(function (e) {
e.preventDefault();
e.stopPropagation();
editfn.call(this, e);
})
.appendTo(document.body);
},
/**
* Creates a "hover" button for image
*
* @param {Function} editfn edition function, called when clicking the button
* @param {Function} stylefn edition style function, called when clicking the button
* @returns {jQuery}
*/
make_hover_button_image: function (editfn, stylefn) {
var $div = $(openerp.qweb.render('website.editor.hoverbutton.media', {}))
.hide()
.appendTo(document.body);
$div.find('[data-toggle="dropdown"]').dropdown();
$div.find(".hover-edition-button").click(function (e) {
e.preventDefault();
e.stopPropagation();
editfn.call(this, e);
});
if (stylefn) {
$div.find(".hover-style-button").click(function (e) {
e.preventDefault();
e.stopPropagation();
stylefn.call(this, e);
});
}
return $div;
},
/**
* For UI clarity, during RTE edition when the user hovers links and
* images a small button should appear to make the capability clear,
* as not all users think of double-clicking the image or link.
*/
setup_hover_buttons: function () {
var editor = this.rte.editor;
var $link_button = this.make_hover_button_link(function () {
var sel = new CKEDITOR.dom.element(previous);
editor.getSelection().selectElement(sel);
if(sel.hasClass('fa')) {
new website.editor.MediaDialog(editor, previous)
.appendTo(document.body);
} else if (previous.tagName.toUpperCase() === 'A') {
link_dialog(editor);
}
$link_button.hide();
previous = null;
});
function is_icons_widget(element) {
var w = editor.widgets.getByElement(element);
return w && w.name === 'icons';
}
// previous is the state of the button-trigger: it's the
// currently-ish hovered element which can trigger a button showing.
// -ish, because when moving to the button itself ``previous`` is
// still set to the element having triggered showing the button.
var previous;
$(editor.element.$).on('mouseover', 'a', function () {
// Back from edit button -> ignore
if (previous && previous === this) { return; }
// hover button should appear for "editable" links and images
// (img and a nodes whose *attributes* are editable, they
// can not be "editing hosts") *or* for non-editing-host
// elements bearing an ``fa`` class. These should have been
// made into CKE widgets which are editing hosts by
// definition, so instead check if the element has been
// converted/upcasted to an fa widget
var selected = new CKEDITOR.dom.element(this);
if (!(is_editable_node(selected) || is_icons_widget(selected))) {
return;
}
previous = this;
var $selected = $(this);
var position = $selected.offset();
$link_button.show().offset({
top: $selected.outerHeight()
+ position.top,
left: $selected.outerWidth() / 2
+ position.left
- $link_button.outerWidth() / 2
})
}).on('mouseleave', 'a, img, .fa', function (e) {
var current = document.elementFromPoint(e.clientX, e.clientY);
if (current === $link_button[0] || $(current).parent()[0] === $link_button[0]) {
return;
}
$link_button.hide();
previous = null;
});
}
});
website.EditorBarCustomize = openerp.Widget.extend({
events: {
'mousedown a.dropdown-toggle': 'load_menu',
'click ul a[data-action!=ace]': 'do_customize',
},
start: function() {
var self = this;
this.$menu = self.$el.find('ul');
this.view_name = $(document.documentElement).data('view-xmlid');
if (!this.view_name) {
this.$el.hide();
}
this.loaded = false;
},
load_menu: function () {
var self = this;
if(this.loaded) {
return;
}
openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': this.view_name }).then(
function(result) {
_.each(result, function (item) {
if (item.xml_id === "website.debugger" && !window.location.search.match(/[&?]debug(&|$)/)) return;
if (item.header) {
self.$menu.append('
',
item.id, item.active ? '-check' : '', item.name));
}
});
self.loaded = true;
}
);
},
do_customize: function (event) {
var view_id = $(event.currentTarget).data('view-id');
return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
model: 'ir.ui.view',
method: 'toggle',
args: [],
kwargs: {
ids: [parseInt(view_id, 10)],
context: website.get_context()
}
}).then( function() {
window.location.reload();
});
},
});
$(document).ready(function() {
var editorBarCustomize = new website.EditorBarCustomize();
editorBarCustomize.setElement($('li[id=customize-menu]'));
editorBarCustomize.start();
});
var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
/* ----- RICH TEXT EDITOR ---- */
website.RTE = openerp.Widget.extend({
tagName: 'li',
id: 'oe_rte_toolbar',
className: 'oe_right oe_rte_toolbar',
// editor.ui.items -> possible commands &al
// editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
init: function (EditorBar) {
this.EditorBar = EditorBar;
this._super.apply(this, arguments);
},
/**
* In Webkit-based browsers, triple-click will select a paragraph up to
* the start of the next "paragraph" including any empty space
* inbetween. When said paragraph is removed or altered, it nukes
* the empty space and brings part of the content of the next
* "paragraph" (which may well be e.g. an image) into the current one,
* completely fucking up layouts and breaking snippets.
*
* Try to fuck around with selections on triple-click to attempt to
* fix this garbage behavior.
*
* Note: for consistent behavior we may actually want to take over
* triple-clicks, in all browsers in order to ensure consistent cross-
* platform behavior instead of being at the mercy of rendering engines
* & platform selection quirks?
*/
webkitSelectionFixer: function (root) {
root.addEventListener('click', function (e) {
// only webkit seems to have a fucked up behavior, ignore others
// FIXME: $.browser goes away in jquery 1.9...
if (!$.browser.webkit) { return; }
// http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
// The detail attribute indicates the number of times a mouse button has been pressed
// we just want the triple click
if (e.detail !== 3) { return; }
e.preventDefault();
// Get closest block-level element to the triple-clicked
// element (using ckeditor's block list because why not)
var $closest_block = $(e.target).closest(blocks_selector);
// manually set selection range to the content of the
// triple-clicked block-level element, to avoid crossing over
// between block-level elements
document.getSelection().selectAllChildren($closest_block[0]);
});
},
tableNavigation: function (root) {
var self = this;
$(root).on('keydown', function (e) {
// ignore non-TAB
if (e.which !== 9) { return; }
if (self.handleTab(e)) {
e.preventDefault();
}
});
},
/**
* Performs whatever operation is necessary on a [TAB] hit, returns
* ``true`` if the event's default should be cancelled (if the TAB was
* handled by the function)
*/
handleTab: function (event) {
var forward = !event.shiftKey;
var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
var $cell = $(root).closest('td,th');
if (!$cell.length) { return false; }
var cell = $cell[0];
// find cell in same row
var row = cell.parentNode;
var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
if (sibling) {
document.getSelection().selectAllChildren(sibling);
return true;
}
// find cell in previous/next row
var table = row.parentNode;
var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
if (sibling_row) {
var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
document.getSelection().selectAllChildren(new_cell);
return true;
}
// at edge cells, copy word/openoffice behavior: if going backwards
// from first cell do nothing, if going forwards from last cell add
// a row
if (forward) {
var row_size = row.cells.length;
var new_row = document.createElement('tr');
while(row_size--) {
var newcell = document.createElement('td');
// zero-width space
newcell.textContent = '\u200B';
new_row.appendChild(newcell);
}
table.appendChild(new_row);
document.getSelection().selectAllChildren(new_row.cells[0]);
}
return true;
},
/**
* Makes the page editable
*
* @param {Boolean} [restart=false] in case the edition was already set
* up once and is being re-enabled.
* @returns {$.Deferred} deferred indicating when the RTE is ready
*/
start_edition: function (restart) {
var self = this;
// create a single editor for the whole page
var root = document.getElementById('wrapwrap');
if (!restart) {
$(root).on('dragstart', 'img', function (e) {
e.preventDefault();
});
this.webkitSelectionFixer(root);
this.tableNavigation(root);
}
var def = $.Deferred();
var editor = this.editor = CKEDITOR.inline(root, self._config());
editor.on('instanceReady', function () {
editor.setReadOnly(false);
// ckeditor set root to editable, disable it (only inner
// sections are editable)
// FIXME: are there cases where the whole editor is editable?
editor.editable().setReadOnly(true);
self.setup_editables(root);
try {
// disable firefox's broken table resizing thing
document.execCommand("enableObjectResizing", false, "false");
document.execCommand("enableInlineTableEditing", false, "false");
} catch (e) {}
// detect & setup any CKEDITOR widget within a newly dropped
// snippet. There does not seem to be a simple way to do it for
// HTML not inserted via ckeditor APIs:
// https://dev.ckeditor.com/ticket/11472
$(document.body)
.off('snippet-dropped')
.on('snippet-dropped', function (e, el) {
// CKEDITOR data processor extended by widgets plugin
// to add wrappers around upcasting elements
el.innerHTML = editor.dataProcessor.toHtml(el.innerHTML, {
fixForBody: false,
dontFilter: true,
});
// then repository.initOnAll() handles the conversion
// from wrapper to actual widget instance (or something
// like that).
setTimeout(function () {
editor.widgets.initOnAll();
}, 0);
});
self.trigger('rte:ready');
def.resolve();
});
return def;
},
setup_editables: function (root) {
// selection of editable sub-items was previously in
// EditorBar#edit, but for some unknown reason the elements were
// apparently removed and recreated (?) at editor initalization,
// and observer setup was lost.
var self = this;
// setup dirty-marking for each editable element
this.fetch_editables(root)
.addClass('oe_editable')
.each(function () {
var node = this;
var $node = $(node);
// only explicitly set contenteditable on view sections,
// cke widgets system will do the widgets themselves
if ($node.data('oe-model') === 'ir.ui.view') {
node.contentEditable = true;
}
observer.observe(node, OBSERVER_CONFIG);
$node.one('content_changed', function () {
$node.addClass('oe_dirty');
self.trigger('change');
});
});
},
fetch_editables: function (root) {
return $(root).find('[data-oe-model]')
.not('link, script')
.not('.oe_snippet_editor')
.filter(function () {
var $this = $(this);
// keep view sections and fields which are *not* in
// view sections for top-level editables
return $this.data('oe-model') === 'ir.ui.view'
|| !$this.closest('[data-oe-model = "ir.ui.view"]').length;
});
},
_current_editor: function () {
return CKEDITOR.currentInstance;
},
_config: function () {
// base plugins minus
// - magicline (captures mousein/mouseout -> breaks draggable)
// - contextmenu & tabletools (disable contextual menu)
// - bunch of unused plugins
var plugins = [
'a11yhelp', 'basicstyles', 'blockquote',
'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
'elementspath', /*'enterkey',*/ 'entities', 'filebrowser',
'find', 'floatingspace','format', 'htmlwriter', 'iframe',
'indentblock', 'indentlist', 'justify',
'list', 'pastefromword', 'pastetext', 'preview',
'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
];
return {
// FIXME
language: 'en',
// Disable auto-generated titles
// FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
title: false,
plugins: plugins.join(','),
uiColor: '',
// FIXME: currently breaks RTE?
// Ensure no config file is loaded
customConfig: '',
// Disable ACF
allowedContent: true,
// Don't insert paragraphs around content in e.g.