flectra/addons/web_editor/static/src/js/editor/rte.js

690 lines
23 KiB
JavaScript

flectra.define('web_editor.rte', function (require) {
'use strict';
var concurrency = require('web.concurrency');
var core = require('web.core');
var Widget = require('web.Widget');
var weContext = require('web_editor.context');
var summernote = require('web_editor.summernote');
var weWidgets = require('web_editor.widget');
var _t = core._t;
// Summernote Lib (neek change to make accessible: method and object)
var dom = summernote.core.dom;
var range = summernote.core.range;
// Change History to have a global History for all summernote instances
var History = function History($editable) {
var aUndo = [];
var pos = 0;
var toSnap;
this.makeSnap = function (event, rng) {
rng = rng || range.create();
var elEditable = $(rng && rng.sc).closest('.o_editable')[0];
if (!elEditable) {
return false;
}
return {
event: event,
editable: elEditable,
contents: elEditable.innerHTML,
bookmark: rng && rng.bookmark(elEditable),
scrollTop: $(elEditable).scrollTop()
};
};
this.applySnap = function (oSnap) {
var $editable = $(oSnap.editable);
if (document.documentMode) {
$editable.removeAttr('contentEditable').removeProp('contentEditable');
}
$editable.html(oSnap.contents).scrollTop(oSnap.scrollTop);
$('.oe_overlay').remove();
$('.note-control-selection').hide();
$editable.trigger('content_changed');
try {
var r = oSnap.editable.innerHTML === '' ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark);
r.select();
} catch (e) {
console.error(e);
return;
}
$(document).trigger('click');
$('.o_editable *').filter(function () {
var $el = $(this);
if ($el.data('snippet-editor')) {
$el.removeData();
}
});
_.defer(function () {
var target = dom.isBR(r.sc) ? r.sc.parentNode : dom.node(r.sc);
if (!target) {
return;
}
$editable.trigger('applySnap');
var evt = document.createEvent('MouseEvents');
evt.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, target);
target.dispatchEvent(evt);
$editable.trigger('keyup');
});
};
this.undo = function () {
if (!pos) { return; }
var _toSnap = toSnap;
if (_toSnap) {
this.saveSnap();
}
if (!aUndo[pos] && (!aUndo[pos] || aUndo[pos].event !== 'undo')) {
var temp = this.makeSnap('undo');
if (temp && (!pos || temp.contents !== aUndo[pos-1].contents)) {
aUndo[pos] = temp;
} else {
pos--;
}
} else if (_toSnap) {
pos--;
}
this.applySnap(aUndo[Math.max(--pos,0)]);
while (pos && (aUndo[pos].event === 'blur' || (aUndo[pos+1].editable === aUndo[pos].editable && aUndo[pos+1].contents === aUndo[pos].contents))) {
this.applySnap(aUndo[--pos]);
}
};
this.hasUndo = function () {
return (toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo')) ||
!!_.find(aUndo.slice(0, pos+1), function (undo) {
return undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo';
});
};
this.getEditableHasUndo = function () {
var editable = [];
if ((toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo'))) {
editable.push(toSnap.editable);
}
_.each(aUndo.slice(0, pos+1), function (undo) {
if (undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo') {
editable.push(undo.editable);
}
});
return _.uniq(editable);
};
this.redo = function () {
if (!aUndo[pos+1]) { return; }
this.applySnap(aUndo[++pos]);
while (aUndo[pos+1] && aUndo[pos].event === 'active') {
this.applySnap(aUndo[pos++]);
}
};
this.hasRedo = function () {
return aUndo.length > pos+1;
};
this.recordUndo = function ($editable, event, internal_history) {
var self = this;
if (!$editable) {
var rng = range.create();
if (!rng) return;
$editable = $(rng.sc).closest('.o_editable');
}
if (aUndo[pos] && (event === 'applySnap' || event === 'activate')) {
return;
}
if (!internal_history) {
if (!event || !toSnap || !aUndo[pos-1] || toSnap.event === 'activate') { // don't trigger change for all keypress
setTimeout(function () {
$editable.trigger('content_changed');
},0);
}
}
if (aUndo[pos]) {
pos = Math.min(pos, aUndo.length);
aUndo.splice(Math.max(pos,1), aUndo.length);
}
// => make a snap when the user change editable zone (because: don't make snap for each keydown)
if (toSnap && (toSnap.split || !event || toSnap.event !== event || toSnap.editable !== $editable[0])) {
this.saveSnap();
}
if (pos && aUndo[pos-1].editable !== $editable[0]) {
var snap = this.makeSnap('blur', range.create(aUndo[pos-1].editable, 0));
pos++;
aUndo.push(snap);
}
if (range.create()) {
toSnap = self.makeSnap(event);
} else {
toSnap = false;
}
};
this.splitNext = function () {
if (toSnap) {
toSnap.split = true;
}
};
this.saveSnap = function () {
if (toSnap) {
if (!aUndo[pos]) {
pos++;
}
aUndo.push(toSnap);
delete toSnap.split;
toSnap = null;
}
};
};
var history = new History();
// jQuery extensions
$.extend($.expr[':'], {
o_editable: function (node, i, m) {
while (node) {
if (node.className && _.isString(node.className)) {
if (node.className.indexOf('o_not_editable')!==-1 ) {
return false;
}
if (node.className.indexOf('o_editable')!==-1 ) {
return true;
}
}
node = node.parentNode;
}
return false;
},
});
$.fn.extend({
focusIn: function () {
if (this.length) {
range.create(dom.firstChild(this[0]), 0).select();
}
return this;
},
focusInEnd: function () {
if (this.length) {
var last = dom.lastChild(this[0]);
range.create(last, dom.nodeLength(last)).select();
}
return this;
},
selectContent: function () {
if (this.length) {
var next = dom.lastChild(this[0]);
range.create(dom.firstChild(this[0]), 0, next, next.textContent.length).select();
}
return this;
},
});
// RTE
var RTEWidget = Widget.extend({
/**
* @constructor
*/
init: function (parent, getConfig) {
var self = this;
this._super.apply(this, arguments);
this.init_bootstrap_carousel = $.fn.carousel;
this.edit_bootstrap_carousel = function () {
var res = self.init_bootstrap_carousel.apply(this, arguments);
// off bootstrap keydown event to remove event.preventDefault()
// and allow to change cursor position
$(this).off('keydown.bs.carousel');
return res;
};
this._getConfig = getConfig || this._getDefaultConfig;
weWidgets.computeFonts();
},
/**
* @override
*/
start: function () {
var self = this;
this.saving_mutex = new concurrency.Mutex();
$.fn.carousel = this.edit_bootstrap_carousel;
$(document).on('mousedown.rte activate.rte', this, this._onMousedown.bind(this));
$(document).on('mouseup.rte', this, this._onMouseup.bind(this));
$('.o_not_editable').attr('contentEditable', false);
var $editable = this.editable();
$editable.addClass('o_editable')
.data('rte', this)
.each(function () {
var $node = $(this);
// fallback for firefox iframe display:none see https://github.com/odoo/odoo/pull/22610
var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this);
// add class to display inline-block for empty t-field
if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') {
$node.addClass('o_is_inline_editable');
}
});
// start element observation
$(document).on('content_changed', '.o_editable', function (ev) {
self.trigger_up('rte_change', {target: ev.target});
if (!ev.__isDirtyHandled) {
$(this).addClass('o_dirty');
ev.__isDirtyHandled = true;
}
});
$('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this));
$('body').addClass('editor_enable');
$(document)
.tooltip({
selector: '[data-oe-readonly]',
container: 'body',
trigger: 'hover',
delay: { 'show': 1000, 'hide': 100 },
placement: 'bottom',
title: _t("Readonly field")
})
.on('click', function () {
$(this).tooltip('hide');
});
$(document).trigger('mousedown');
this.trigger('rte:start');
return this._super.apply(this, arguments);
},
/**
* @override
*/
destroy: function () {
this.cancel();
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Stops the RTE.
*/
cancel: function () {
if (this.$last) {
this.$last.destroy();
this.$last = null;
}
$.fn.carousel = this.init_bootstrap_carousel;
$(document).off('.rte');
$('#wrapwrap, .o_editable').off('.rte');
$('.o_not_editable').removeAttr('contentEditable');
$(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte');
$(document).tooltip('destroy');
$('body').removeClass('editor_enable');
this.trigger('rte:stop');
},
/**
* Returns the editable areas on the page.
*
* @returns {jQuery}
*/
editable: function () {
return $('#wrapwrap [data-oe-model]')
.not('.o_not_editable')
.filter(function () {
return !$(this).closest('.o_not_editable').length;
})
.not('link, script')
.not('[data-oe-readonly]')
.not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]')
.not('.oe_snippet_editor')
.add('.o_editable');
},
/**
* Records the current state of the given $target to be able to undo future
* changes.
*
* @see History.recordUndo
* @param {jQuery} $target
* @param {string} event
* @param {boolean} internal_history
*/
historyRecordUndo: function ($target, event, internal_history) {
$target = $($target);
var rng = range.create();
var $editable = $(rng && rng.sc).closest('.o_editable');
if (!rng || !$editable.length) {
$editable = $target.closest('.o_editable');
rng = range.create($target.closest('*')[0],0);
} else {
rng = $editable.data('range') || rng;
}
try {
// TODO this line might break for unknown reasons. I suppose that
// the created range is an invalid one. As it might be tricky to
// adapt that line and that it is not a critical one, temporary fix
// is to ignore the errors that this generates.
rng.select();
} catch (e) {
console.log('error', e);
}
history.recordUndo($editable, event, internal_history);
},
/**
* Searches all the dirty element on the page and saves them one by one. If
* one cannot be saved, this notifies it to the user and restarts rte
* edition.
*
* @param {Object} [context] - the context to use for saving rpc, default to
* the editor context found on the page
* @return {Deferred} rejected if the save cannot be done
*/
save: function (context) {
var self = this;
$('.o_editable')
.destroy()
.removeClass('o_editable o_is_inline_editable');
var $dirty = $('.o_dirty');
$dirty
.removeAttr('contentEditable')
.removeClass('o_dirty oe_carlos_danger o_is_inline_editable');
var defs = _.map($dirty, function (el) {
var $el = $(el);
$el.find('[class]').filter(function () {
if (!this.getAttribute('class').match(/\S/)) {
this.removeAttribute('class');
}
});
// 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, context || weContext.get())
.then(function () {
$el.removeClass('o_dirty');
}, function (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('o_dirty oe_carlos_danger ' + id);
var html = (response.data.exception_type === 'except_osv');
if (html) {
var msg = $('<div/>', {text: response.data.message}).html();
var data = msg.substring(3, msg.length -2).split(/', u'/);
response.data.message = '<b>' + data[0] + '</b>' + data[1];
}
$('.o_editable.' + id)
.removeClass(id)
.popover({
html: html,
trigger: 'hover',
content: response.data.message,
placement: 'auto top',
})
.popover('show');
});
});
});
return $.when.apply($, defs).then(function () {
window.onbeforeunload = null;
}, function (failed) {
// If there were errors, re-enable edition
self.cancel();
self.start();
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* When the users clicks on an editable element, this function allows to add
* external behaviors.
*
* @private
* @param {jQuery} $editable
*/
_enableEditableArea: function ($editable) {
if ($editable.data('oe-type') === "monetary") {
$editable.attr('contenteditable', false);
$editable.find('.oe_currency_value').attr('contenteditable', true);
}
if ($editable.is('[data-oe-model]') && !$editable.is('[data-oe-model="ir.ui.view"]') && !$editable.is('[data-oe-type="html"]')) {
$editable.data('layoutInfo').popover().find('.btn-group:not(.note-history)').remove();
}
},
/**
* When an element enters edition, summernote is initialized on it. This
* function returns the default configuration for the summernote instance.
*
* @see _getConfig
* @private
* @param {jQuery} $editable
* @returns {Object}
*/
_getDefaultConfig: function ($editable) {
return {
'airMode' : true,
'focus': false,
'airPopover': [
['style', ['style']],
['font', ['bold', 'italic', 'underline', 'clear']],
['fontsize', ['fontsize']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture']],
['history', ['undo', 'redo']],
],
'styleWithSpan': false,
'inlinemedia' : ['p'],
'lang': 'flectra',
'onChange': function (html, $editable) {
$editable.trigger('content_changed');
}
};
},
/**
* Gets jQuery cloned element with internal text nodes escaped for XML
* storage.
*
* @private
* @param {jQuery} $el
* @return {jQuery}
*/
_getEscapedElement: function ($el) {
var escaped_el = $el.clone();
var to_escape = escaped_el.find('*').addBack();
to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack());
to_escape.contents().each(function () {
if (this.nodeType === 3) {
this.nodeValue = $('<div />').text(this.nodeValue).html();
}
});
return escaped_el;
},
/**
* Saves one (dirty) element of the page.
*
* @private
* @param {jQuery} $el - the element to save
* @param {Object} context - the context to use for the saving rpc
* @param {boolean} [withLang=false]
* false if the lang must be omitted in the context (saving "master"
* page element)
*/
_saveElement: function ($el, context, withLang) {
return this._rpc({
model: 'ir.ui.view',
method: 'save',
args: [
$el.data('oe-id'),
this._getEscapedElement($el).prop('outerHTML'),
$el.data('oe-xpath') || null,
withLang ? context : _.omit(context, 'lang')
],
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when any editable element is clicked -> Prevents default browser
* action for the element.
*
* @private
* @param {Event} e
*/
_onClick: function (e) {
e.preventDefault();
},
/**
* Called when the mouse is pressed on the document -> activate element
* edition.
*
* @private
* @param {Event} ev
*/
_onMousedown: function (ev) {
var $target = $(ev.target);
var $editable = $target.closest('.o_editable');
if (!$editable.length || $.summernote.core.dom.isContentEditableFalse($target)) {
return;
}
if ($target.is('a')) {
/**
* Remove content editable everywhere and add it on the link only so that characters can be added
* and removed at the start and at the end of it.
*/
$target.attr('contenteditable', true);
_.defer(function () {
$editable.not($target).attr('contenteditable', false);
$target.focus();
});
// Once clicked outside, remove contenteditable on link and reactive all
$(document).on('mousedown.reactivate_contenteditable', function (e) {
if ($target.is(e.target)) return;
$target.removeAttr('contenteditable');
$editable.attr('contenteditable', true);
$(document).off('mousedown.reactivate_contenteditable');
});
}
if (this && this.$last && (!$editable.length || this.$last[0] !== $editable[0])) {
var $destroy = this.$last;
history.splitNext();
_.delay(function () {
var id = $destroy.data('note-id');
$destroy.destroy().removeData('note-id').removeAttr('data-note-id');
$('#note-popover-'+id+', #note-handle-'+id+', #note-dialog-'+id+'').remove();
}, 150); // setTimeout to remove flickering when change to editable zone (re-create an editor)
this.$last = null;
}
if ($editable.length && (!this.$last || this.$last[0] !== $editable[0])) {
$editable.summernote(this._getConfig($editable));
$editable.data('NoteHistory', history);
this.$last = $editable;
// firefox & IE fix
try {
document.execCommand('enableObjectResizing', false, false);
document.execCommand('enableInlineTableEditing', false, false);
document.execCommand('2D-position', false, false);
} catch (e) { /* */ }
document.body.addEventListener('resizestart', function (evt) {evt.preventDefault(); return false;});
document.body.addEventListener('movestart', function (evt) {evt.preventDefault(); return false;});
document.body.addEventListener('dragstart', function (evt) {evt.preventDefault(); return false;});
if (!range.create()) {
$editable.focusIn();
}
if (dom.isImg($target[0])) {
$target.trigger('mousedown'); // for activate selection on picture
}
this._enableEditableArea($editable);
}
},
/**
* Called when the mouse is unpressed on the document.
*
* @private
* @param {Event} ev
*/
_onMouseup: function (ev) {
var $target = $(ev.target);
var $editable = $target.closest('.o_editable');
if (!$editable.length) {
return;
}
var self = this;
_.defer(function () {
self.historyRecordUndo($target, 'activate', true);
});
// Browsers select different content from one to another after a
// triple click (especially: if triple-clicking on a paragraph on
// Chrome, blank characters of the element following the paragraph are
// selected too)
//
// The triple click behavior is reimplemented for all browsers here
if (ev.originalEvent.detail === 3) {
// Select the whole content inside the deepest DOM element that was
// triple-clicked
range.create(ev.target, 0, ev.target, ev.target.childNodes.length).select();
}
},
});
return {
Class: RTEWidget,
history: history,
};
});