diff options
Diffstat (limited to 'addons/web_editor/static/src/js/editor')
| -rw-r--r-- | addons/web_editor/static/src/js/editor/editor.js | 286 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/editor/rte.js | 749 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/editor/rte.summernote.js | 1277 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/editor/snippets.editor.js | 1453 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/editor/snippets.options.js | 1331 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/editor/summernote.js | 2452 | ||||
| -rw-r--r-- | addons/web_editor/static/src/js/editor/transcoder.js | 395 |
7 files changed, 7943 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js new file mode 100644 index 00000000000..63fa8c1c667 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/editor.js @@ -0,0 +1,286 @@ +odoo.define('web_editor.editor', function (require) { +'use strict'; + +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var core = require('web.core'); +var rte = require('web_editor.rte'); +var snippetsEditor = require('web_editor.snippet.editor'); + +var _t = core._t; + +var EditorMenuBar = Widget.extend({ + template: 'web_editor.editorbar', + xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], + events: { + 'click button[data-action=save]': '_onSaveClick', + 'click button[data-action=cancel]': '_onCancelClick', + }, + custom_events: { + request_history_undo_record: '_onHistoryUndoRecordRequest', + request_save: '_onSaveRequest', + }, + + /** + * Initializes RTE and snippets menu. + * + * @constructor + */ + init: function (parent) { + var self = this; + var res = this._super.apply(this, arguments); + this.rte = new rte.Class(this); + this.rte.on('rte:start', this, function () { + self.trigger('rte:start'); + }); + + // Snippets edition + var $editable = this.rte.editable(); + window.__EditorMenuBar_$editable = $editable; // TODO remove this hack asap + this.snippetsMenu = new snippetsEditor.Class(this, $editable); + + return res; + }, + /** + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + + core.bus.on('editor_save_request', this, this.save); + core.bus.on('editor_discard_request', this, this.cancel); + + $('.dropdown-toggle').dropdown(); + + $(document).on('keyup', function (event) { + if ((event.keyCode === 8 || event.keyCode === 46)) { + var $target = $(event.target).closest('.o_editable'); + if (!$target.is(':has(*:not(p):not(br))') && !$target.text().match(/\S/)) { + $target.empty(); + } + } + }); + $(document).on('click', '.note-editable', function (ev) { + ev.preventDefault(); + }); + $(document).on('submit', '.note-editable form .btn', function (ev) { + ev.preventDefault(); // Disable form submition in editable mode + }); + $(document).on('hide.bs.dropdown', '.dropdown', function (ev) { + // Prevent dropdown closing when a contenteditable children is focused + if (ev.originalEvent + && $(ev.target).has(ev.originalEvent.target).length + && $(ev.originalEvent.target).is('[contenteditable]')) { + ev.preventDefault(); + } + }); + + this.rte.start(); + + var flag = false; + window.onbeforeunload = function (event) { + if (rte.history.getEditableHasUndo().length && !flag) { + flag = true; + _.defer(function () { flag=false; }); + return _t('This document is not saved!'); + } + }; + + // Snippets menu + defs.push(this.snippetsMenu.insertAfter(this.$el)); + this.rte.editable().find('*').off('mousedown mouseup click'); + + return $.when.apply($, defs).then(function () { + self.trigger_up('edit_mode'); + }); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + core.bus.off('editor_save_request', this, this._onSaveRequest); + core.bus.off('editor_discard_request', this, this._onDiscardRequest); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks the user if he really wants to discard its changes (if there are + * some of them), then simply reload the page if he wants to. + * + * @param {boolean} [reload=true] + * true if the page has to be reloaded when the user answers yes + * (do nothing otherwise but add this to allow class extension) + * @returns {Deferred} + */ + cancel: function (reload) { + var self = this; + var def = $.Deferred(); + if (!rte.history.getEditableHasUndo().length) { + def.resolve(); + } else { + var confirm = Dialog.confirm(this, _t("If you discard the current edition, all unsaved changes will be lost. You can cancel to return to the edition mode."), { + confirm_callback: def.resolve.bind(def), + }); + confirm.on('closed', def, def.reject); + } + return def.then(function () { + if (reload !== false) { + window.onbeforeunload = null; + return self._reload(); + } + }); + }, + /** + * Asks the snippets to clean themself, then saves the page, then reloads it + * if asked to. + * + * @param {boolean} [reload=true] + * true if the page has to be reloaded after the save + * @returns {Deferred} + */ + save: function (reload) { + var self = this; + var defs = []; + this.trigger_up('ready_to_save', {defs: defs}); + return $.when.apply($, defs).then(function () { + self.snippetsMenu.cleanForSave(); + return self._saveCroppedImages(); + }).then(function () { + return self.rte.save(); + }).then(function () { + if (reload !== false) { + return self._reload(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Reloads the page in non-editable mode, with the right scrolling. + * + * @private + * @returns {Deferred} (never resolved, the page is reloading anyway) + */ + _reload: function () { + window.location.hash = 'scrollTop=' + window.document.body.scrollTop; + if (window.location.search.indexOf('enable_editor') >= 0) { + window.location.href = window.location.href.replace(/&?enable_editor(=[^&]*)?/g, ''); + } else { + window.location.reload(true); + } + return $.Deferred(); + }, + /** + * @private + */ + _saveCroppedImages: function () { + var self = this; + var defs = _.map(this.rte.editable().find('.o_cropped_img_to_save'), function (croppedImg) { + var $croppedImg = $(croppedImg); + $croppedImg.removeClass('o_cropped_img_to_save'); + + var resModel = $croppedImg.data('crop:resModel'); + var resID = $croppedImg.data('crop:resID'); + var cropID = $croppedImg.data('crop:id'); + var mimetype = $croppedImg.data('crop:mimetype'); + var originalSrc = $croppedImg.data('crop:originalSrc'); + + var datas = $croppedImg.attr('src').split(',')[1]; + + if (!cropID) { + var name = originalSrc + '.crop'; + return self._rpc({ + model: 'ir.attachment', + method: 'create', + args: [{ + res_model: resModel, + res_id: resID, + name: name, + datas_fname: name, + datas: datas, + mimetype: mimetype, + url: originalSrc, // To save the original image that was cropped + }], + }).then(function (attachmentID) { + return self._rpc({ + model: 'ir.attachment', + method: 'generate_access_token', + args: [[attachmentID]], + }).then(function (access_token) { + $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]); + }); + }); + } else { + return self._rpc({ + model: 'ir.attachment', + method: 'write', + args: [[cropID], {datas: datas}], + }); + } + }); + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Discard" button is clicked -> discards the changes. + * + * @private + */ + _onCancelClick: function () { + this.cancel(); + }, + /** + * Called when an element askes to record an history undo -> records it. + * + * @private + * @param {OdooEvent} ev + */ + _onHistoryUndoRecordRequest: function (ev) { + this.rte.historyRecordUndo(ev.data.$target, ev.data.event); + }, + /** + * Called when the "Save" button is clicked -> saves the changes. + * + * @private + */ + _onSaveClick: function () { + this.save(); + }, + /** + * Called when a discard request is received -> discard the page content + * changes. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardRequest: function (ev) { + this.cancel(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure); + }, + /** + * Called when a save request is received -> saves the page content. + * + * @private + * @param {OdooEvent} ev + */ + _onSaveRequest: function (ev) { + ev.stopPropagation(); + this.save(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure); + }, +}); + +return { + Class: EditorMenuBar, +}; +}); diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js new file mode 100644 index 00000000000..ba6e1fed7ec --- /dev/null +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -0,0 +1,749 @@ +odoo.define('web_editor.rte', function (require) { +'use strict'; + +var base = require('web_editor.base'); +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 summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); + +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.trigger('content_will_be_destroyed'); + $editable.html(oSnap.contents).scrollTop(oSnap.scrollTop); + $editable.trigger('content_was_recreated'); + + $('.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(pos, 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; + + base.computeFonts(); + }, + /** + * @override + */ + start: function () { + var self = this; + + this.saving_mutex = new concurrency.Mutex(); + + $.fn.carousel = this.edit_bootstrap_carousel; + + $(document).on('click.rte keyup.rte', function () { + var current_range = {}; + try { + current_range = range.create() || {}; + } catch (e) { + // if range is on Restricted element ignore error + } + var $popover = $(current_range.sc).closest('[contenteditable]'); + var popover_history = ($popover.data()||{}).NoteHistory; + if (!popover_history || popover_history === history) return; + var editor = $popover.parent('.note-editor'); + $('button[data-event="undo"]', editor).attr('disabled', !popover_history.hasUndo()); + $('button[data-event="redo"]', editor).attr('disabled', !popover_history.hasRedo()); + }); + $(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(); + + // When a undo/redo is performed, the whole DOM is changed so we have + // to prepare for it (website will restart animations for example) + // TODO should be better handled + $editable.on('content_will_be_destroyed', function (ev) { + self.trigger_up('content_will_be_destroyed', { + $target: $(ev.currentTarget), + }); + }); + $editable.on('content_was_recreated', function (ev) { + self.trigger_up('content_was_recreated', { + $target: $(ev.currentTarget), + }); + }); + + $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.body) + .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('click.rte keyup.rte mousedown.rte activate.rte mouseup.rte'); + $(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte'); + $(document).tooltip('dispose'); + $('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': 'odoo', + 'onChange': function (html, $editable) { + $editable.trigger('content_changed'); + }, + 'colors': summernoteCustomColors, + }; + }, + /** + * 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, + ], + context: context, + }, withLang ? undefined : { + noContextKeys: '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; + } + + // Removes strange _moz_abspos attribute when it appears. Cannot + // find another solution which works in all cases. A grabber still + // appears at the same time which I did not manage to remove. + // TODO find a complete and better solution + _.defer(function () { + $editable.find('[_moz_abspos]').removeAttr('_moz_abspos'); + }); + + 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, +}; +}); + +odoo.define('web_editor.rte.summernote_custom_colors', function (require) { +'use strict'; + +return [ + ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'], + ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'], + ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'], + ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'], + ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'], + ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'], + ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'], + ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031'] +]; +}); diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js new file mode 100644 index 00000000000..464c58ed4ee --- /dev/null +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -0,0 +1,1277 @@ +odoo.define('web_editor.rte.summernote', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var Class = require('web.Class'); +var core = require('web.core'); +var ColorpickerDialog = require('web.colorpicker'); +var mixins = require('web.mixins'); +var base = require('web_editor.base'); +var weContext = require('web_editor.context'); +var rte = require('web_editor.rte'); +var weWidgets = require('web_editor.widget'); + +var QWeb = core.qweb; +var _t = core._t; + +ajax.jsonRpc('/web/dataset/call', 'call', { + 'model': 'ir.ui.view', + 'method': 'read_template', + 'args': ['web_editor.colorpicker', weContext.get()] +}).done(function (data) { + QWeb.add_template(data); +}); + +// Summernote Lib (neek change to make accessible: method and object) +var dom = $.summernote.core.dom; +var range = $.summernote.core.range; +var eventHandler = $.summernote.eventHandler; +var renderer = $.summernote.renderer; + +var tplButton = renderer.getTemplate().button; +var tplIconButton = renderer.getTemplate().iconButton; +var tplDropdown = renderer.getTemplate().dropdown; + +function _rgbToHex(cssColor) { + var rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); + if (!rgba) { + return cssColor; + } + if (rgba[4]) { + return cssColor; + } + var hex = ColorpickerDialog.prototype.convertRgbToHex( + parseInt(rgba[1]), + parseInt(rgba[2]), + parseInt(rgba[3]) + ); + if (!hex) { + return cssColor; // TODO handle error + } + return hex.hex.toUpperCase(); +} + +// Update and change the popovers content, and add history button +var fn_createPalette = renderer.createPalette; +renderer.createPalette = function ($container, options) { + fn_createPalette.call(this, $container, options); + + if (!QWeb.has_template('web_editor.colorpicker')) { + return; + } + + var $clpicker = $(QWeb.render('web_editor.colorpicker')); + + var groups; + if ($clpicker.is("colorpicker")) { + groups = _.map($clpicker.find('[data-name="theme"]'), function (el) { + return $(el).find("button").empty(); + }); + } else { + groups = [$clpicker.find("button").empty()]; + } + + var html = "<h6 class='mt-2'>" + _t("Theme colors") + "</h6>" + _.map(groups, function ($group) { + var $row = $("<div/>", {"class": "note-color-row mb8"}).append($group); + var $after_breaks = $row.find(".o_small + :not(.o_small)"); + if ($after_breaks.length === 0) { + $after_breaks = $row.find(":nth-child(8n+9)"); + } + $after_breaks.addClass("o_clear"); + return $row[0].outerHTML; + }).join("") + "<h6 class='mt-2'>" + _t("Common colors") + "</h6>"; + var $palettes = $container.find(".note-color .note-color-palette"); + $palettes.prepend(html); + + // Find the custom colors which are used in the page and add them to the color palette + var colors = []; + var $editable = window.__EditorMenuBar_$editable || $(); + _.each($editable.find('[style*="color"]'), function (element) { + if (element.style.color) { + colors.push(element.style.color); + } + if (element.style.backgroundColor) { + colors.push(element.style.backgroundColor); + } + }); + + var $customColorPalettes = $container.find('.note-color .note-custom-color-palette').append($('<div/>', {class: "note-color-row"})); + var $customColorRows = $customColorPalettes.find('.note-color-row'); + _.each(_.uniq(colors), function (color) { + var hexColor = _rgbToHex(color); + if (_.indexOf(_.flatten(options.colors), hexColor) < 0) { + // Create button for used custom color for backColor and foreColor both and add them into palette + $customColorRows.append('<button type="button" class="o_custom_color" data-color="' + color + '" style="background-color:' + color + ';" />'); + } + }); + + $palettes.push.apply($palettes, $customColorPalettes); + + var $fore = $palettes.filter(":even").find("button:not(.note-color-btn)").addClass("note-color-btn"); + var $bg = $palettes.filter(":odd").find("button:not(.note-color-btn)").addClass("note-color-btn"); + $fore.each(function () { + var $el = $(this); + var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'text-' + $el.data('color'); + $el.attr('data-event', 'foreColor').attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : 'bg-' + $el.data('color')); + }); + $bg.each(function () { + var $el = $(this); + var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'bg-' + $el.data('color'); + $el.attr('data-event', 'backColor').attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : className); + }); +}; + +var fn_tplPopovers = renderer.tplPopovers; +renderer.tplPopovers = function (lang, options) { + var $popover = $(fn_tplPopovers.call(this, lang, options)); + + var $imagePopover = $popover.find('.note-image-popover'); + var $linkPopover = $popover.find('.note-link-popover'); + var $airPopover = $popover.find('.note-air-popover'); + + //////////////// image popover + + // add center button for images + $(tplIconButton('fa fa-align-center', { + title: _t('Center'), + event: 'floatMe', + value: 'center' + })).insertAfter($imagePopover.find('[data-event="floatMe"][data-value="left"]')); + $imagePopover.find('button[data-event="removeMedia"]').parent().remove(); + $imagePopover.find('button[data-event="floatMe"][data-value="none"]').remove(); + + // padding button + var $padding = $('<div class="btn-group"/>'); + $padding.insertBefore($imagePopover.find('.btn-group:first')); + var dropdown_content = [ + '<li><a class="dropdown-item" data-event="padding" href="#" data-value="">'+_t('None')+'</a></li>', + '<li><a class="dropdown-item" data-event="padding" href="#" data-value="small">'+_t('Small')+'</a></li>', + '<li><a class="dropdown-item" data-event="padding" href="#" data-value="medium">'+_t('Medium')+'</a></li>', + '<li><a class="dropdown-item" data-event="padding" href="#" data-value="large">'+_t('Large')+'</a></li>', + '<li><a class="dropdown-item" data-event="padding" href="#" data-value="xl">'+_t('Xl')+'</a></li>', + ]; + $(tplIconButton('fa fa-plus-square-o', { + title: _t('Padding'), + dropdown: tplDropdown(dropdown_content) + })).appendTo($padding); + + // circle, boxed... options became toggled + $imagePopover.find('[data-event="imageShape"]:not([data-value])').remove(); + var $button = $(tplIconButton('fa fa-sun-o', { + title: _t('Shadow'), + event: 'imageShape', + value: 'shadow' + })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="rounded-circle"]')); + + // add spin for fa + var $spin = $('<div class="btn-group d-none only_fa"/>').insertAfter($button.parent()); + $(tplIconButton('fa fa-refresh', { + title: _t('Spin'), + event: 'imageShape', + value: 'fa-spin' + })).appendTo($spin); + + // resize for fa + var $resizefa = $('<div class="btn-group d-none only_fa"/>') + .insertAfter($imagePopover.find('.btn-group:has([data-event="resize"])')); + for (var size=1; size<=5; size++) { + $(tplButton('<span class="note-fontsize-10">'+size+'x</span>', { + title: size+"x", + event: 'resizefa', + value: size+'' + })).appendTo($resizefa); + } + var $colorfa = $airPopover.find('.note-color').clone(); + $colorfa.find(".dropdown-menu").css('min-width', '172px'); + $resizefa.after($colorfa); + + // show dialog box and delete + var $imageprop = $('<div class="btn-group"/>'); + $imageprop.appendTo($imagePopover.find('.popover-body')); + $(tplIconButton('fa fa-file-image-o', { + title: _t('Edit'), + event: 'showImageDialog' + })).appendTo($imageprop); + $(tplIconButton('fa fa-trash-o', { + title: _t('Remove'), + event: 'delete' + })).appendTo($imageprop); + + $(tplIconButton('fa fa-crop', { + title: _t('Crop Image'), + event: 'cropImage', + })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="img-thumbnail"]')); + + $imagePopover.find('.popover-body').append($airPopover.find(".note-history").clone()); + + $imagePopover.find('[data-event="showImageDialog"]').before($airPopover.find('[data-event="showLinkDialog"]').clone()); + + var $alt = $('<div class="btn-group"/>'); + $alt.appendTo($imagePopover.find('.popover-body')); + $alt.append('<button class="btn btn-secondary" data-event="alt"><strong>' + _t('Description') + ': </strong><span class="o_image_alt"/></button>'); + + //////////////// link popover + + $linkPopover.find('.popover-body').append($airPopover.find(".note-history").clone()); + + $linkPopover.find('button[data-event="showLinkDialog"] i').attr("class", "fa fa-link"); + $linkPopover.find('button[data-event="unlink"]').before($airPopover.find('button[data-event="showImageDialog"]').clone()); + + //////////////// text/air popover + + //// highlight the text format + $airPopover.find('.note-style .dropdown-toggle').on('mousedown', function () { + var $format = $airPopover.find('[data-event="formatBlock"]'); + var node = range.create().sc; + var formats = $format.map(function () { return $(this).data("value"); }).get(); + while (node && (!node.tagName || (!node.tagName || formats.indexOf(node.tagName.toLowerCase()) === -1))) { + node = node.parentNode; + } + $format.removeClass('active'); + $format.filter('[data-value="'+(node ? node.tagName.toLowerCase() : "p")+'"]') + .addClass("active"); + }); + + //////////////// tooltip + + setTimeout(function () { + $airPopover.add($linkPopover).add($imagePopover).find("button") + .tooltip('dispose') + .tooltip({ + container: 'body', + trigger: 'hover', + placement: 'bottom' + }).on('click', function () {$(this).tooltip('hide');}); + }); + + return $popover; +}; + +var fn_boutton_update = eventHandler.modules.popover.button.update; +eventHandler.modules.popover.button.update = function ($container, oStyle) { + // stop animation when edit content + var previous = $(".note-control-selection").data('target'); + if (previous) { + var $previous = $(previous); + $previous.css({"-webkit-animation-play-state": "", "animation-play-state": "", "-webkit-transition": "", "transition": "", "-webkit-animation": "", "animation": ""}); + $previous.find('.o_we_selected_image').addBack('.o_we_selected_image').removeClass('o_we_selected_image'); + } + // end + + fn_boutton_update.call(this, $container, oStyle); + + $container.find('.note-color').removeClass('d-none'); + + if (oStyle.image) { + $container.find('[data-event]').removeClass("active"); + + $container.find('a[data-event="padding"][data-value="small"]').toggleClass("active", $(oStyle.image).hasClass("padding-small")); + $container.find('a[data-event="padding"][data-value="medium"]').toggleClass("active", $(oStyle.image).hasClass("padding-medium")); + $container.find('a[data-event="padding"][data-value="large"]').toggleClass("active", $(oStyle.image).hasClass("padding-large")); + $container.find('a[data-event="padding"][data-value="xl"]').toggleClass("active", $(oStyle.image).hasClass("padding-xl")); + $container.find('a[data-event="padding"][data-value=""]').toggleClass("active", !$container.find('li a.active[data-event="padding"]').length); + + $(oStyle.image).addClass('o_we_selected_image'); + + if (dom.isImgFont(oStyle.image)) { + $container.find('[data-event="customColor"][data-value="foreColor"]').attr('data-color', $(oStyle.image).css('color')); + $container.find('[data-event="customColor"][data-value="backColor"]').attr('data-color', $(oStyle.image).css('background-color')); + $container.find('.note-fore-color-preview > button').css('border-bottom-color', $(oStyle.image).css('color')); + $container.find('.note-back-color-preview > button').css('border-bottom-color', $(oStyle.image).css('background-color')); + + $container.find('.btn-group:not(.only_fa):has(button[data-event="resize"],button[data-value="img-thumbnail"])').addClass('d-none'); + $container.find('.only_fa').removeClass('d-none'); + $container.find('button[data-event="resizefa"][data-value="2"]').toggleClass("active", $(oStyle.image).hasClass("fa-2x")); + $container.find('button[data-event="resizefa"][data-value="3"]').toggleClass("active", $(oStyle.image).hasClass("fa-3x")); + $container.find('button[data-event="resizefa"][data-value="4"]').toggleClass("active", $(oStyle.image).hasClass("fa-4x")); + $container.find('button[data-event="resizefa"][data-value="5"]').toggleClass("active", $(oStyle.image).hasClass("fa-5x")); + $container.find('button[data-event="resizefa"][data-value="1"]').toggleClass("active", !$container.find('.active[data-event="resizefa"]').length); + + $container.find('button[data-event="imageShape"][data-value="fa-spin"]').toggleClass("active", $(oStyle.image).hasClass("fa-spin")); + $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow")); + $container.find('.btn-group:has(button[data-event="imageShape"])').removeClass("d-none"); + + } else { + $container.find('.d-none:not(.only_fa, .note-recent-color)').removeClass('d-none'); + $container.find('.only_fa').addClass('d-none'); + var width = ($(oStyle.image).attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+%)/); + if (width) { + width = width[2]; + } + $container.find('button[data-event="resize"][data-value="auto"]').toggleClass("active", width !== "100%" && width !== "50%" && width !== "25%"); + $container.find('button[data-event="resize"][data-value="1"]').toggleClass("active", width === "100%"); + $container.find('button[data-event="resize"][data-value="0.5"]').toggleClass("active", width === "50%"); + $container.find('button[data-event="resize"][data-value="0.25"]').toggleClass("active", width === "25%"); + + $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow")); + + if (!$(oStyle.image).is("img")) { + $container.find('.btn-group:has(button[data-event="imageShape"])').addClass('d-none'); + } + + $container.find('.note-color').addClass('d-none'); + } + + $container.find('button[data-event="floatMe"][data-value="left"]').toggleClass("active", $(oStyle.image).hasClass("float-left")); + $container.find('button[data-event="floatMe"][data-value="center"]').toggleClass("active", $(oStyle.image).hasClass("d-block mx-auto")); + $container.find('button[data-event="floatMe"][data-value="right"]').toggleClass("active", $(oStyle.image).hasClass("float-right")); + + $(oStyle.image).trigger('attributes_change'); + } else { + $container.find('[data-event="customColor"][data-value="foreColor"]').attr('data-color', oStyle.color); + $container.find('[data-event="customColor"][data-value="backColor"]').attr('data-color', oStyle['background-color']); + $container.find('.note-fore-color-preview > button').css('border-bottom-color', oStyle.color); + $container.find('.note-back-color-preview > button').css('border-bottom-color', oStyle['background-color']); + } +}; + +var fn_popover_update = eventHandler.modules.popover.update; +eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) { + var $imagePopover = $popover.find('.note-image-popover'); + var $linkPopover = $popover.find('.note-link-popover'); + var $airPopover = $popover.find('.note-air-popover'); + + fn_popover_update.call(this, $popover, oStyle, isAirMode); + + if (oStyle.image) { + if (oStyle.image.parentNode.className.match(/(^|\s)media_iframe_video(\s|$)/i)) { + oStyle.image = oStyle.image.parentNode; + } + var alt = $(oStyle.image).attr("alt"); + + $imagePopover.find('.o_image_alt').text( (alt || "").replace(/"/g, '"') ).parent().toggle(oStyle.image.tagName === "IMG"); + $imagePopover.show(); + + // for video tag (non-void) we select the range over the tag, + // for other media types we get the first descendant leaf element + var target_node = oStyle.image; + if (!oStyle.image.className.match(/(^|\s)media_iframe_video(\s|$)/i)) { + target_node = dom.firstChild(target_node); + } + range.createFromNode(target_node).select(); + // save range on the editor so it is not lost if restored + eventHandler.modules.editor.saveRange(dom.makeLayoutInfo(target_node).editable()); + } else { + $(".note-control-selection").hide(); + } + + if (oStyle.image || (oStyle.range && (!oStyle.range.isCollapsed() || (oStyle.range.sc.tagName && !dom.isAnchor(oStyle.range.sc)))) || (oStyle.image && !$(oStyle.image).closest('a').length)) { + $linkPopover.hide(); + oStyle.anchor = false; + } + + if (oStyle.image || oStyle.anchor || (oStyle.range && !$(oStyle.range.sc).closest('.note-editable').length)) { + $airPopover.hide(); + } else { + $airPopover.show(); + } +}; + +var fn_handle_update = eventHandler.modules.handle.update; +eventHandler.modules.handle.update = function ($handle, oStyle, isAirMode) { + fn_handle_update.call(this, $handle, oStyle, isAirMode); + if (oStyle.image) { + $handle.find('.note-control-selection').hide(); + } +}; + +// Hack for image and link editor +function getImgTarget($editable) { + var $handle = $editable ? dom.makeLayoutInfo($editable).handle() : undefined; + return $(".note-control-selection", $handle).data('target'); +} +eventHandler.modules.editor.padding = function ($editable, sValue) { + var $target = $(getImgTarget($editable)); + var paddings = "small medium large xl".split(/\s+/); + $editable.data('NoteHistory').recordUndo(); + if (sValue.length) { + paddings.splice(paddings.indexOf(sValue),1); + $target.toggleClass('padding-'+sValue); + } + $target.removeClass("padding-" + paddings.join(" padding-")); +}; +eventHandler.modules.editor.resize = function ($editable, sValue) { + var $target = $(getImgTarget($editable)); + $editable.data('NoteHistory').recordUndo(); + var width = ($target.attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+)%/); + if (width) { + width = width[2]/100; + } + $target.css('width', (width !== sValue && sValue !== "auto") ? (sValue * 100) + '%' : ''); +}; +eventHandler.modules.editor.resizefa = function ($editable, sValue) { + var $target = $(getImgTarget($editable)); + $editable.data('NoteHistory').recordUndo(); + $target.attr('class', $target.attr('class').replace(/\s*fa-[0-9]+x/g, '')); + if (+sValue > 1) { + $target.addClass('fa-'+sValue+'x'); + } +}; +eventHandler.modules.editor.floatMe = function ($editable, sValue) { + var $target = $(getImgTarget($editable)); + $editable.data('NoteHistory').recordUndo(); + switch (sValue) { + case 'center': $target.toggleClass('d-block mx-auto').removeClass('float-right float-left'); break; + case 'left': $target.toggleClass('float-left').removeClass('float-right d-block mx-auto'); break; + case 'right': $target.toggleClass('float-right').removeClass('float-left d-block mx-auto'); break; + } +}; +eventHandler.modules.editor.imageShape = function ($editable, sValue) { + var $target = $(getImgTarget($editable)); + $editable.data('NoteHistory').recordUndo(); + $target.toggleClass(sValue); +}; + +eventHandler.modules.linkDialog.showLinkDialog = function ($editable, $dialog, linkInfo) { + $editable.data('range').select(); + $editable.data('NoteHistory').recordUndo(); + + var def = new $.Deferred(); + core.bus.trigger('link_dialog_demand', { + $editable: $editable, + linkInfo: linkInfo, + onSave: function (linkInfo) { + linkInfo.range.select(); + $editable.data('range', linkInfo.range); + def.resolve(linkInfo); + $editable.trigger('keyup'); + $('.note-popover .note-link-popover').show(); + }, + onCancel: def.reject.bind(def), + }); + return def; +}; +eventHandler.modules.imageDialog.showImageDialog = function ($editable) { + var r = $editable.data('range'); + if (r.sc.tagName && r.sc.childNodes.length) { + r.sc = r.sc.childNodes[r.so]; + } + var media = $(r.sc).parents().addBack().filter(function (i, el) { + return dom.isImg(el); + })[0]; + core.bus.trigger('media_dialog_demand', { + $editable: $editable, + media: media, + options: { + lastFilters: ['background'], + onUpload: $editable.data('callbacks').onUpload, + noVideos: $editable.data('oe-model') === "mail.compose.message", + }, + }); + return new $.Deferred().reject(); +}; +$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) { + var $editable = layoutInfo.editable(); + var $selection = layoutInfo.handle().find('.note-control-selection'); + core.bus.trigger('alt_dialog_demand', { + $editable: $editable, + media: $selection.data('target'), + }); +}; +$.summernote.pluginEvents.customColor = function (event, editor, layoutInfo, customColor) { + var defaultColor = event.target.dataset.color; + core.bus.trigger('color_picker_dialog_demand', { + color: defaultColor === 'rgba(0, 0, 0, 0)' ? 'rgb(255, 0, 0)' : defaultColor, + onSave: function (color) { + var $palettes = $(event.currentTarget).find('.note-custom-color-palette > .note-color-row') + .append(('<button type="button" class="note-color-btn" data-value="' + color + '" style="background-color:' + color + ';" />')); + $palettes.filter(':odd').find('button:not([data-event])').attr('data-event', 'backColor'); + $palettes.filter(':even').find('button:not([data-event])').attr('data-event', 'foreColor'); + if (customColor === 'foreColor') { + $(event.currentTarget).find('.note-fore-color-preview > button').css('border-bottom-color', color); + $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, color); + } else { + $(event.currentTarget).find('.note-back-color-preview > button').css('border-bottom-color', color); + $.summernote.pluginEvents.backColor(event, editor, layoutInfo, color); + } + } + }); +}; +$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) { + var $editable = layoutInfo.editable(); + var $selection = layoutInfo.handle().find('.note-control-selection'); + core.bus.trigger('crop_image_dialog_demand', { + $editable: $editable, + media: $selection.data('target'), + }); +}; + +// Utils +var fn_is_void = dom.isVoid || function () {}; +dom.isVoid = function (node) { + return fn_is_void(node) || dom.isImgFont(node) || (node && node.className && node.className.match(/(^|\s)media_iframe_video(\s|$)/i)); +}; +var fn_is_img = dom.isImg || function () {}; +dom.isImg = function (node) { + return fn_is_img(node) || dom.isImgFont(node) || (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)(media_iframe_video|o_image)(\s|$)/i)) )); +}; +var fn_is_forbidden_node = dom.isForbiddenNode || function () {}; +dom.isForbiddenNode = function (node) { + if (node.tagName === "BR") { + return false; + } + return fn_is_forbidden_node(node) || $(node).is(".media_iframe_video"); +}; +var fn_is_img_font = dom.isImgFont || function () {}; +dom.isImgFont = function (node) { + if (fn_is_img_font(node)) return true; + + var nodeName = node && node.nodeName.toUpperCase(); + var className = (node && node.className || ""); + if (node && (nodeName === "SPAN" || nodeName === "I") && className.length) { + var classNames = className.split(/\s+/); + for (var k=0; k<base.fontIcons.length; k++) { + if (_.intersection(base.fontIcons[k].alias, classNames).length) { + return true; + } + } + } + return false; +}; +var fn_is_font = dom.isFont; // re-overwrite font to include theme icons +dom.isFont = function (node) { + return fn_is_font(node) || dom.isImgFont(node); +}; + +var fn_visible = $.summernote.pluginEvents.visible; +$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) { + var res = fn_visible.apply(this, arguments); + var rng = range.create(); + if (!rng) return res; + var $node = $(dom.node(rng.sc)); + if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) && + $node.hasClass("o_editable") && + !$node[0].children.length && + "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) { + var p = $('<p><br/></p>')[0]; + $node.append( p ); + range.createFromNode(p.firstChild).select(); + } + return res; +}; + +function prettify_html(html) { + html = html.trim(); + var result = '', + level = 0, + get_space = function (level) { + var i = level, space = ''; + while (i--) space += ' '; + return space; + }, + reg = /^<\/?(a|span|font|u|em|i|strong|b)(\s|>)/i, + inline_level = Infinity, + tokens = _.compact(_.flatten(_.map(html.split(/</), function (value) { + value = value.replace(/\s+/g, ' ').split(/>/); + value[0] = /\S/.test(value[0]) ? '<' + value[0] + '>' : ''; + return value; + }))); + + // reduce => merge inline style + text + + for (var i = 0, l = tokens.length; i < l; i++) { + var token = tokens[i]; + var inline_tag = reg.test(token); + var inline = inline_tag || inline_level <= level; + + if (token[0] === '<' && token[1] === '/') { + if (inline_tag && inline_level === level) { + inline_level = Infinity; + } + level--; + } + + if (!inline && !/\S/.test(token)) { + continue; + } + if (!inline || (token[1] !== '/' && inline_level > level)) { + result += get_space(level); + } + + if (token[0] === '<' && token[1] !== '/') { + level++; + if (inline_tag && inline_level > level) { + inline_level = level; + } + } + + if (token.match(/^<(img|hr|br)/)) { + level--; + } + + // don't trim inline content (which could change appearance) + if (!inline) { + token = token.trim(); + } + + result += token.replace(/\s+/, ' '); + + if (inline_level > level) { + result += '\n'; + } + } + return result; +} + +/* + * This override when clicking on the 'Code View' button has two aims: + * + * - have our own code view implementation for FieldTextHtml + * - add an 'enable' paramater to call the function directly and allow us to + * disable (false) or enable (true) the code view mode. + */ +$.summernote.pluginEvents.codeview = function (event, editor, layoutInfo, enable) { + if (layoutInfo === undefined) { + return; + } + if (layoutInfo.toolbar) { + // if editor inline (FieldTextHtmlSimple) + var is_activated = $.summernote.eventHandler.modules.codeview.isActivated(layoutInfo); + if (is_activated === enable) { + return; + } + return eventHandler.modules.codeview.toggle(layoutInfo); + } else { + // if editor iframe (FieldTextHtml) + var $editor = layoutInfo.editor(); + var $textarea = $editor.prev('textarea'); + if ($textarea.is('textarea') === enable) { + return; + } + + if (!$textarea.length) { + // init and create texarea + var html = prettify_html($editor.prop("innerHTML")); + $editor.parent().css({ + 'position': 'absolute', + 'top': 0, + 'bottom': 0, + 'left': 0, + 'right': 0 + }); + $textarea = $('<textarea/>').css({ + 'margin': '0 -4px', + 'padding': '0 4px', + 'border': 0, + 'top': '51px', + 'left': '620px', + 'width': '100%', + 'font-family': 'sans-serif', + 'font-size': '13px', + 'height': '98%', + 'white-space': 'pre', + 'word-wrap': 'normal' + }).val(html).data('init', html); + $editor.before($textarea); + $editor.hide(); + } else { + // save changes + $editor.prop('innerHTML', $textarea.val().replace(/\s*\n\s*/g, '')).trigger('content_changed'); + $textarea.remove(); + $editor.show(); + } + } +}; + +// Fix ie and re-range to don't break snippet +var last_div; +var last_div_change; +var last_editable; +var initial_data = {}; +function reRangeSelectKey(event) { + initial_data.range = null; + if (event.shiftKey && event.keyCode >= 37 && event.keyCode <= 40 && !$(event.target).is("input, textarea, select")) { + var r = range.create(); + if (r) { + var rng = r.reRange(event.keyCode <= 38); + if (r !== rng) { + rng.select(); + } + } + } +} +function reRangeSelect(event, dx, dy) { + var r = range.create(); + if (!r || r.isCollapsed()) return; + + // check if the user move the caret on up or down + var data = r.reRange(dy < 0 || (dy === 0 && dx < 0)); + + if (data.sc !== r.sc || data.so !== r.so || data.ec !== r.ec || data.eo !== r.eo) { + setTimeout(function () { + data.select(); + $(data.sc.parentNode).closest('.note-popover'); + },0); + } + + $(data.sc).closest('.o_editable').data('range', r); + return r; +} +function summernote_mouseup(event) { + if ($(event.target).closest("#web_editor-top-navbar, .note-popover").length) { + return; + } + // don't rerange if simple click + if (initial_data.event) { + var dx = event.clientX - (event.shiftKey && initial_data.rect ? initial_data.rect.left : initial_data.event.clientX); + var dy = event.clientY - (event.shiftKey && initial_data.rect ? initial_data.rect.top : initial_data.event.clientY); + if (10 < Math.pow(dx, 2)+Math.pow(dy, 2)) { + reRangeSelect(event, dx, dy); + } + } + + if (!$(event.target).closest(".o_editable").length) { + return; + } + if (!initial_data.range || !event.shiftKey) { + setTimeout(function () { + initial_data.range = range.create(); + },0); + } +} +var remember_selection; +function summernote_mousedown(event) { + rte.history.splitNext(); + + var $editable = $(event.target).closest(".o_editable, .note-editor"); + var r; + + if (document.documentMode) { + summernote_ie_fix(event, function (node) { return node.tagName === "DIV" || node.tagName === "IMG" || (node.dataset && node.dataset.oeModel); }); + } else if (last_div && event.target !== last_div) { + if (last_div.tagName === "A") { + summernote_ie_fix(event, function (node) { return node.dataset && node.dataset.oeModel; }); + } else if ($editable.length) { + if (summernote_ie_fix(event, function (node) { return node.tagName === "A"; })) { + r = range.create(); + r.select(); + } + } + } + + // restore range if range lost after clicking on non-editable area + try { + r = range.create(); + } catch (e) { + // If this code is running inside an iframe-editor and that the range + // is outside of this iframe, this will fail as the iframe does not have + // the permission to check the outside content this way. In that case, + // we simply ignore the exception as it is as if there was no range. + return; + } + var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]"); + var r_editable = editables.has((r||{}).sc).addBack(editables.filter((r||{}).sc)); + if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) { + var saved_editable = editables.has((remember_selection||{}).sc); + if ($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) { + remember_selection = range.create(dom.firstChild($editable[0]), 0); + } else if (!saved_editable.length) { + remember_selection = undefined; + } + if (remember_selection) { + try { + remember_selection.select(); + } catch (e) { + console.warn(e); + } + } + } else if (r_editable.length) { + remember_selection = r; + } + + initial_data.event = event; + + // keep selection when click with shift + if (event.shiftKey && $editable.length) { + if (initial_data.range) { + initial_data.range.select(); + } + var rect = r && r.getClientRects(); + initial_data.rect = rect && rect.length ? rect[0] : { top: 0, left: 0 }; + } +} + +function summernote_ie_fix(event, pred) { + var editable; + var div; + var node = event.target; + while (node.parentNode) { + if (!div && pred(node)) { + div = node; + } + if (last_div !== node && (node.getAttribute('contentEditable')==='false' || node.className && (node.className.indexOf('o_not_editable') !== -1))) { + break; + } + if (node.className && node.className.indexOf('o_editable') !== -1) { + if (!div) { + div = node; + } + editable = node; + break; + } + node = node.parentNode; + } + + if (!editable) { + $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable"); + $(last_editable).attr("contentEditable", "true").prop("contentEditable", "true"); + last_div_change = null; + last_editable = null; + return; + } + + if (div === last_div) { + return; + } + + last_div = div; + + $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable"); + + if (last_editable !== editable) { + if ($(editable).is("[contentEditable='true']")) { + $(editable).removeAttr("contentEditable").removeProp("contentEditable"); + last_editable = editable; + } else { + last_editable = null; + } + } + if (!$(div).attr("contentEditable") && !$(div).is("[data-oe-type='many2one'], [data-oe-type='contact']")) { + $(div).attr("contentEditable", "true").prop("contentEditable", "true"); + last_div_change = div; + } else { + last_div_change = null; + } + return editable !== div ? div : null; +} + +var fn_attach = eventHandler.attach; +eventHandler.attach = function (oLayoutInfo, options) { + fn_attach.call(this, oLayoutInfo, options); + + oLayoutInfo.editor().on('dragstart', 'img', function (e) { e.preventDefault(); }); + $(document).on('mousedown', summernote_mousedown).on('mouseup', summernote_mouseup); + oLayoutInfo.editor().off('click').on('click', function (e) {e.preventDefault();}); // if the content editable is a link + + /** + * Open Media Dialog on double click on an image/video/icon. + * Shows a tooltip on click to say to the user he can double click. + */ + create_dblclick_feature("img, .media_iframe_video, i.fa, span.fa, a.o_image", function () { + eventHandler.modules.imageDialog.show(oLayoutInfo); + }); + + /** + * Open Link Dialog on double click on a link/button. + * Shows a tooltip on click to say to the user he can double click. + */ + create_dblclick_feature("a[href], a.btn, button.btn", function () { + eventHandler.modules.linkDialog.show(oLayoutInfo); + }); + + oLayoutInfo.editable().on('mousedown', function (e) { + if (dom.isImg(e.target) && dom.isContentEditable(e.target)) { + range.createFromNode(e.target).select(); + } + }); + $(document).on("keyup", reRangeSelectKey); + + var clone_data = false; + + if (options.model) { + oLayoutInfo.editable().data({'oe-model': options.model, 'oe-id': options.id}); + } + if (options.getMediaDomain) { + oLayoutInfo.editable().data('oe-media-domain', options.getMediaDomain); + } + + var $node = oLayoutInfo.editor(); + if ($node.data('oe-model') || $node.data('oe-translation-id')) { + $node.on('content_changed', function () { + var $nodes = $('[data-oe-model], [data-oe-translation-id]') + .filter(function () { return this !== $node[0];}); + + if ($node.data('oe-model')) { + $nodes = $nodes.filter('[data-oe-model="'+$node.data('oe-model')+'"]') + .filter('[data-oe-id="'+$node.data('oe-id')+'"]') + .filter('[data-oe-field="'+$node.data('oe-field')+'"]'); + } + if ($node.data('oe-translation-id')) $nodes = $nodes.filter('[data-oe-translation-id="'+$node.data('oe-translation-id')+'"]'); + if ($node.data('oe-type')) $nodes = $nodes.filter('[data-oe-type="'+$node.data('oe-type')+'"]'); + if ($node.data('oe-expression')) $nodes = $nodes.filter('[data-oe-expression="'+$node.data('oe-expression')+'"]'); + if ($node.data('oe-xpath')) $nodes = $nodes.filter('[data-oe-xpath="'+$node.data('oe-xpath')+'"]'); + if ($node.data('oe-contact-options')) $nodes = $nodes.filter('[data-oe-contact-options="'+$node.data('oe-contact-options')+'"]'); + + var nodes = $node.get(); + + if ($node.data('oe-type') === "many2one") { + $nodes = $nodes.add($('[data-oe-model]') + .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; }) + .filter('[data-oe-many2one-model="'+$node.data('oe-many2one-model')+'"]') + .filter('[data-oe-many2one-id="'+$node.data('oe-many2one-id')+'"]') + .filter('[data-oe-type="many2one"]')); + + $nodes = $nodes.add($('[data-oe-model]') + .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; }) + .filter('[data-oe-model="'+$node.data('oe-many2one-model')+'"]') + .filter('[data-oe-id="'+$node.data('oe-many2one-id')+'"]') + .filter('[data-oe-field="name"]')); + } + + if (!clone_data) { + clone_data = true; + $nodes.html(this.innerHTML); + clone_data = false; + } + }); + } + + var custom_toolbar = oLayoutInfo.toolbar ? oLayoutInfo.toolbar() : undefined; + var $toolbar = $(oLayoutInfo.popover()).add(custom_toolbar); + $('button[data-event="undo"], button[data-event="redo"]', $toolbar).attr('disabled', true); + + $(oLayoutInfo.editor()) + .add(oLayoutInfo.handle()) + .add(oLayoutInfo.popover()) + .add(custom_toolbar) + .on('click content_changed', function () { + $('button[data-event="undo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasUndo()); + $('button[data-event="redo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasRedo()); + }); + + function create_dblclick_feature(selector, callback) { + var show_tooltip = true; + + oLayoutInfo.editor().on("dblclick", selector, function (e) { + var $target = $(e.target); + if (!dom.isContentEditable($target)) { + // Prevent edition of non editable parts + return; + } + + show_tooltip = false; + callback(); + e.stopImmediatePropagation(); + }); + + oLayoutInfo.editor().on("click", selector, function (e) { + var $target = $(e.target); + if (!dom.isContentEditable($target)) { + // Prevent edition of non editable parts + return; + } + + show_tooltip = true; + setTimeout(function () { + // Do not show tooltip on double-click and if there is already one + if (!show_tooltip || $target.attr('title') !== undefined) { + return; + } + $target.tooltip({title: _t('Double-click to edit'), trigger: 'manuel', container: 'body'}).tooltip('show'); + setTimeout(function () { + $target.tooltip('dispose'); + }, 800); + }, 400); + }); + } +}; +var fn_detach = eventHandler.detach; +eventHandler.detach = function (oLayoutInfo, options) { + fn_detach.call(this, oLayoutInfo, options); + oLayoutInfo.editable().off('mousedown'); + oLayoutInfo.editor().off("dragstart"); + oLayoutInfo.editor().off('click'); + $(document).off('mousedown', summernote_mousedown); + $(document).off('mouseup', summernote_mouseup); + oLayoutInfo.editor().off("dblclick"); + $(document).off("keyup", reRangeSelectKey); +}; + +// Translation for odoo +$.summernote.lang.odoo = { + font: { + bold: _t('Bold'), + italic: _t('Italic'), + underline: _t('Underline'), + strikethrough: _t('Strikethrough'), + subscript: _t('Subscript'), + superscript: _t('Superscript'), + clear: _t('Remove Font Style'), + height: _t('Line Height'), + name: _t('Font Family'), + size: _t('Font Size') + }, + image: { + image: _t('File / Image'), + insert: _t('Insert Image'), + resizeFull: _t('Resize Full'), + resizeHalf: _t('Resize Half'), + resizeQuarter: _t('Resize Quarter'), + floatLeft: _t('Float Left'), + floatRight: _t('Float Right'), + floatNone: _t('Float None'), + dragImageHere: _t('Drag an image here'), + selectFromFiles: _t('Select from files'), + url: _t('Image URL'), + remove: _t('Remove Image') + }, + link: { + link: _t('Link'), + insert: _t('Insert Link'), + unlink: _t('Unlink'), + edit: _t('Edit'), + textToDisplay: _t('Text to display'), + url: _t('To what URL should this link go?'), + openInNewWindow: _t('Open in new window') + }, + video: { + video: _t('Video'), + videoLink: _t('Video Link'), + insert: _t('Insert Video'), + url: _t('Video URL?'), + providers: _t('(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)') + }, + table: { + table: _t('Table') + }, + hr: { + insert: _t('Insert Horizontal Rule') + }, + style: { + style: _t('Style'), + normal: _t('Normal'), + blockquote: _t('Quote'), + pre: _t('Code'), + small: _t('Small'), + h1: _t('Header 1'), + h2: _t('Header 2'), + h3: _t('Header 3'), + h4: _t('Header 4'), + h5: _t('Header 5'), + h6: _t('Header 6') + }, + lists: { + unordered: _t('Unordered list'), + ordered: _t('Ordered list') + }, + options: { + help: _t('Help'), + fullscreen: _t('Full Screen'), + codeview: _t('Code View') + }, + paragraph: { + paragraph: _t('Paragraph'), + outdent: _t('Outdent'), + indent: _t('Indent'), + left: _t('Align left'), + center: _t('Align center'), + right: _t('Align right'), + justify: _t('Justify full') + }, + color: { + custom: _t('Custom Color'), + background: _t('Background Color'), + foreground: _t('Font Color'), + transparent: _t('Transparent'), + setTransparent: _t('None'), + }, + shortcut: { + shortcuts: _t('Keyboard shortcuts'), + close: _t('Close'), + textFormatting: _t('Text formatting'), + action: _t('Action'), + paragraphFormatting: _t('Paragraph formatting'), + documentStyle: _t('Document Style') + }, + history: { + undo: _t('Undo'), + redo: _t('Redo') + } +}; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +/** + * @todo get rid of this. This has been implemented as a fix to be able to + * instantiate media, link and alt dialogs outside the main editor: in the + * simple HTML fields and forum textarea. + */ +var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, { + /** + * @constructor + */ + init: function (parent) { + mixins.EventDispatcherMixin.init.call(this); + this.setParent(parent); + + core.bus.on('alt_dialog_demand', this, this._onAltDialogDemand); + core.bus.on('color_picker_dialog_demand', this, this._onColorPickerDialogDemand); + core.bus.on('crop_image_dialog_demand', this, this._onCropImageDialogDemand); + core.bus.on('link_dialog_demand', this, this._onLinkDialogDemand); + core.bus.on('media_dialog_demand', this, this._onMediaDialogDemand); + }, + /** + * @override + */ + destroy: function () { + mixins.EventDispatcherMixin.destroy.call(this); + + core.bus.off('alt_dialog_demand', this, this._onAltDialogDemand); + core.bus.off('color_picker_dialog_demand', this, this._onColorPickerDialogDemand); + core.bus.off('crop_image_dialog_demand', this, this._onCropImageDialogDemand); + core.bus.off('link_dialog_demand', this, this._onLinkDialogDemand); + core.bus.off('media_dialog_demand', this, this._onMediaDialogDemand); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a demand to open a alt dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onAltDialogDemand: function (data) { + if (data.__alreadyDone) { + return; + } + data.__alreadyDone = true; + var altDialog = new weWidgets.AltDialog(this, + data.options || {}, + data.$editable, + data.media + ); + if (data.onSave) { + altDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + altDialog.on('cancel', this, data.onCancel); + } + altDialog.open(); + }, +/** + * Called when a demand to open a color picker dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onColorPickerDialogDemand: function (data) { + if (data.__alreadyDone) { + return; + } + data.__alreadyDone = true; + var colorpicker = new ColorpickerDialog(this, { + defaultColor: data.color, + }); + if (data.onSave) { + colorpicker.on('colorpicker:saved', this, function (ev) { + data.onSave(ev.data.cssColor); + }); + } + colorpicker.open(); + }, + /** + * Called when a demand to open a crop dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onCropImageDialogDemand: function (data) { + if (data.__alreadyDone) { + return; + } + data.__alreadyDone = true; + var cropImageDialog = new weWidgets.CropImageDialog(this, + _.extend({ + res_model: data.$editable.data('oe-model'), + res_id: data.$editable.data('oe-id'), + }, data.options || {}), + data.$editable, + data.media + ); + if (data.onSave) { + cropImageDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + cropImageDialog.on('cancel', this, data.onCancel); + } + cropImageDialog.open(); + }, + /** + * Called when a demand to open a link dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onLinkDialogDemand: function (data) { + if (data.__alreadyDone) { + return; + } + data.__alreadyDone = true; + var linkDialog = new weWidgets.LinkDialog(this, + data.options || {}, + data.$editable, + data.linkInfo + ); + if (data.onSave) { + linkDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + linkDialog.on('cancel', this, data.onCancel); + } + linkDialog.open(); + }, + /** + * Called when a demand to open a media dialog is received on the bus. + * + * @private + * @param {Object} data + */ + _onMediaDialogDemand: function (data) { + if (data.__alreadyDone) { + return; + } + data.__alreadyDone = true; + + var mediaDialog = new weWidgets.MediaDialog(this, + _.extend({ + res_model: data.$editable.data('oe-model'), + res_id: data.$editable.data('oe-id'), + domain: data.$editable.data('oe-media-domain'), + }, data.options), + data.$editable, + data.media + ); + if (data.onSave) { + mediaDialog.on('save', this, data.onSave); + } + if (data.onCancel) { + mediaDialog.on('cancel', this, data.onCancel); + } + mediaDialog.open(); + }, +}); +/** + * @todo cannot do this without include because it would make a loop in the + * JS module dependencies otherwise. + */ +rte.Class.include({ + /** + * @override + */ + start: function () { + this._summernoteManager = new SummernoteManager(this); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + cancel: function () { + this._super.apply(this, arguments); + this._summernoteManager.destroy(); + }, +}); +return SummernoteManager; +}); diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js new file mode 100644 index 00000000000..776d9992bb7 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -0,0 +1,1453 @@ +odoo.define('web_editor.snippet.editor', function (require) { +'use strict'; + +var core = require('web.core'); +var dom = require('web.dom'); +var Widget = require('web.Widget'); +var options = require('web_editor.snippets.options'); + +var _t = core._t; + +var globalSelector = { + closest: function () { return $(); }, + all: function () { return $(); }, + is: function () { return false; }, +}; + +/** + * Management of the overlay and option list for a snippet. + */ +var SnippetEditor = Widget.extend({ + template: 'web_editor.snippet_overlay', + xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], + events: { + 'click .oe_snippet_parent': '_onParentButtonClick', + 'click .oe_snippet_clone': '_onCloneClick', + 'click .oe_snippet_remove': '_onRemoveClick', + }, + custom_events: { + cover_update: '_onCoverUpdate', + option_update: '_onOptionUpdate', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {Element} target + * @param templateOptions + */ + init: function (parent, target, templateOptions) { + this._super.apply(this, arguments); + this.$target = $(target); + this.$target.data('snippet-editor', this); + this.templateOptions = templateOptions; + }, + /** + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + + // Hide parent button if no parent + var $parent = globalSelector.closest(this.$target.parent()); + if (!$parent.length) { + this.$('.oe_snippet_parent').remove(); + } + + // Initialize the associated options (see snippets.options.js) + defs.push(this._initializeOptions()); + + // Initialize move/clone/remove buttons + if (!this.$target.parent().is(':o_editable')) { + this.$el.find('.oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove').remove(); + } else { + this.dropped = false; + this.$el.draggable({ + greedy: true, + appendTo: 'body', + cursor: 'move', + handle: '.oe_snippet_move', + cursorAt: { + left: 18, + top: 14 + }, + helper: function () { + var $clone = $(this).clone().css({width: '24px', height: '24px', border: 0}); + $clone.find('.oe_overlay_options >:not(:contains(.oe_snippet_move)), .o_handle').remove(); + $clone.find(':not(.glyphicon)').css({position: 'absolute', top: 0, left: 0}); + $clone.appendTo('body').removeClass('d-none'); + return $clone; + }, + start: _.bind(self._onDragAndDropStart, self), + stop: _.bind(self._onDragAndDropStop, self) + }); + } + + this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', function () { + self._targetIsAnimated = true; + }); + this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', function () { + self._targetIsAnimated = false; + if (self.$el.is('.oe_active')) { + self.cover(); + } + }); + + return $.when.apply($, defs); + }, + /** + * @override + */ + destroy: function () { + this.cleanForSave(); + this._super.apply(this, arguments); + this.$target.removeData('snippet-editor'); + this.$target.off('.snippet_editor'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Notifies all the associated snippet options that the snippet has just + * been dropped in the page. + */ + buildSnippet: function () { + for (var i in this.styles) { + this.styles[i].onBuilt(); + } + }, + /** + * Notifies all the associated snippet options that the template which + * contains the snippet is about to be saved. + */ + cleanForSave: function () { + if (this.isDestroyed()) { + return; + } + _.each(this.styles, function (option) { + option.cleanForSave(); + }); + }, + /** + * Makes the editor overlay cover the associated snippet. + */ + cover: function () { + if (this._targetIsAnimated) { + // Do not cover a target being animated, it will be covered once the + // animation is completed. + return; + } + var offset = this.$target.offset(); + var manipulatorOffset = this.$el.parent().offset(); + offset.top -= manipulatorOffset.top; + offset.left -= manipulatorOffset.left; + this.$el.css({ + width: this.$target.outerWidth(), + left: offset.left, + top: offset.top, + }); + this.$('.o_handles').css('height', this.$target.outerHeight()); + this.$el.toggleClass('o_top_cover', offset.top < 15); + }, + /** + * Removes the associated snippet from the DOM and destroys the associated + * editor (itself). + */ + removeSnippet: function () { + this.toggleFocus(false); + + this.trigger_up('call_for_each_child_snippet', { + $snippet: this.$target, + callback: function (editor, $snippet) { + for (var i in editor.styles) { + editor.styles[i].onRemove(); + } + }, + }); + + var $parent = this.$target.parent(); + this.$target.find('*').andSelf().tooltip('dispose'); + this.$target.remove(); + this.$el.remove(); + + var node = $parent[0]; + if (node && node.firstChild) { + $.summernote.core.dom.removeSpace(node, node.firstChild, 0, node.lastChild, 1); + if (!node.firstChild.tagName && node.firstChild.textContent === ' ') { + node.removeChild(node.firstChild); + } + } + + if ($parent.closest(':data("snippet-editor")').length) { + while (!$parent.data('snippet-editor')) { + var $nextParent = $parent.parent(); + if ($parent.children().length === 0 && $parent.text().trim() === '' && !$parent.hasClass('oe_structure')) { + $parent.remove(); + } + $parent = $nextParent; + } + if ($parent.children().length === 0 && $parent.text().trim() === '' && !$parent.hasClass('oe_structure')) { + _.defer(function () { + $parent.data('snippet-editor').removeSnippet(); + }); + } + } + + // clean editor if they are image or table in deleted content + $('.note-control-selection').hide(); + $('.o_table_handler').remove(); + + this.trigger_up('snippet_removed'); + this.destroy(); + }, + /** + * Displays/Hides the editor overlay and notifies the associated snippet + * options. Note: when it is displayed, this is here that the parent + * snippet options are moved to the editor overlay. + * + * @param {boolean} focus - true to display, false to hide + */ + toggleFocus: function (focus) { + var do_action = (focus ? _do_action_focus : _do_action_blur); + + // Attach own and parent options on the current overlay + var $style_button = this.$el.find('.oe_options'); + var $ul = $style_button.find('.dropdown-menu:first'); + var $headers = $ul.find('.dropdown-header:data(editor)'); + _.each($headers, (function (el) { + var $el = $(el); + var styles = _.values($el.data('editor').styles); + if ($el.data('editor') !== this) { + styles = _.filter(styles, function (option) { return !option.preventChildPropagation; }); + } + + var count = 0; + _.each(_.sortBy(styles, '__order').reverse(), function (style) { + if (do_action(style, $el)) { + count++; + } + }); + $el.toggleClass('d-none', count === 0); + }).bind(this)); + + // Activate the overlay + $style_button.toggleClass('d-none', $ul.children(':not(.o_main_header):not(.dropdown-divider):not(.d-none)').length === 0); + this.cover(); + this.$el.toggleClass('oe_active', !!focus); + + function _do_action_focus(style, $dest) { + style.$el.insertAfter($dest); + style.onFocus(); + return (style.$el.length > 0); + } + function _do_action_blur(style, $dest) { + style.$el.detach(); + style.onBlur(); + return false; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * DOMElements have a default name which appears in the overlay when they + * are being edited. This method retrieves this name; it can be defined + * directly in the DOM thanks to the `data-name` attribute. + * + * @private + */ + _getName: function () { + if (this.$target.data('name') !== undefined) { + return this.$target.data('name'); + } + if (this.$target.parent('.row').length) { + return _t("Column"); + } + return _t("Block"); + }, + /** + * Instantiates the snippet's options. + * + * @private + */ + _initializeOptions: function () { + var self = this; + var $styles = this.$el.find('.oe_options'); + var $ul = $styles.find('.dropdown-menu:first'); + this.styles = {}; + this.selectorSiblings = []; + this.selectorChildren = []; + + var i = 0; + $ul.append($('<div/>', {class: 'dropdown-header o_main_header', text: this._getName()}).data('editor', this)); + var defs = _.map(this.templateOptions, function (val, option_id) { + if (!val.selector.is(self.$target)) { + return; + } + if (val['drop-near']) self.selectorSiblings.push(val['drop-near']); + if (val['drop-in']) self.selectorChildren.push(val['drop-in']); + + var optionName = val.option; + var $el = val.$el.children().clone(true).addClass('snippet-option-' + optionName); + var option = new (options.registry[optionName] || options.Class)( + self, + val.base_target ? self.$target.find(val.base_target).eq(0) : self.$target, + self.$el, + val.data + ); + self.styles[optionName || _.uniqueId('option')] = option; + option.__order = i++; + return option.attachTo($el); + }); + $ul.append($('<div/>', {class: 'dropdown-divider mt-2'})); + + var $parents = this.$target.parents(); + _.each($parents, function (parent) { + var parentEditor = $(parent).data('snippet-editor'); + if (parentEditor) { + for (var styleName in parentEditor.styles) { + if (!parentEditor.styles[styleName].preventChildPropagation) { + $ul.append($('<div/>', {class: 'dropdown-header o_parent_editor_header', text: parentEditor._getName()}).data('editor', parentEditor)); + break; + } + } + } + }); + + if (!this.selectorSiblings.length && !this.selectorChildren.length) { + this.$el.find('.oe_snippet_move, .oe_snippet_clone').addClass('d-none'); + } + + this.$el.find('[data-toggle="dropdown"]').dropdown(); + + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the 'clone' button is clicked. + * + * @private + * @param {Event} ev + */ + _onCloneClick: function (ev) { + ev.preventDefault(); + + this.trigger_up('snippet_will_be_cloned', {$target: this.$target}); + + var $clone = this.$target.clone(false); + + this.trigger_up('request_history_undo_record', {$target: this.$target}); + + this.$target.after($clone); + this.trigger_up('call_for_each_child_snippet', { + $snippet: $clone, + callback: function (editor, $snippet) { + for (var i in editor.styles) { + editor.styles[i].onClone({ + isCurrent: ($snippet.is($clone)), + }); + } + }, + }); + this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target}); + }, + /** + * Called when the overlay dimensions/positions should be recomputed. + * + * @private + */ + _onCoverUpdate: function () { + this.cover(); + }, + /** + * Called when the snippet is starting to be dragged thanks to the 'move' + * button. + * + * @private + */ + _onDragAndDropStart: function () { + var self = this; + this.dropped = false; + self.size = { + width: self.$target.width(), + height: self.$target.height() + }; + self.$target.after('<div class="oe_drop_clone" style="display: none;"/>'); + self.$target.detach(); + self.$el.addClass('d-none'); + + var $selectorSiblings; + for (var i = 0 ; i < self.selectorSiblings.length ; i++) { + if (!$selectorSiblings) $selectorSiblings = self.selectorSiblings[i].all(); + else $selectorSiblings = $selectorSiblings.add(self.selectorSiblings[i].all()); + } + var $selectorChildren; + for (i = 0 ; i < self.selectorChildren.length ; i++) { + if (!$selectorChildren) $selectorChildren = self.selectorChildren[i].all(); + else $selectorChildren = $selectorChildren.add(self.selectorChildren[i].all()); + } + + this.trigger_up('go_to_parent', {$snippet: this.$target}); + this.trigger_up('activate_insertion_zones', { + $selectorSiblings: $selectorSiblings, + $selectorChildren: $selectorChildren, + }); + + $('body').addClass('move-important'); + + $('.oe_drop_zone').droppable({ + over: function () { + $('.oe_drop_zone.hide').removeClass('hide'); + $(this).addClass('hide').first().after(self.$target); + self.dropped = true; + }, + out: function () { + $(this).removeClass('hide'); + self.$target.detach(); + self.dropped = false; + }, + }); + }, + /** + * Called when the snippet is dropped after being dragged thanks to the + * 'move' button. + * + * @private + * @param {Event} ev + * @param {Object} ui + */ + _onDragAndDropStop: function (ev, ui) { + var self = this; + + // TODO lot of this is duplicated code of the d&d feature of snippets + if (!this.dropped) { + var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone').first(); + if ($el.length) { + $el.after(this.$target); + this.dropped = true; + } + } + + $('.oe_drop_zone').droppable('destroy').remove(); + + var prev = this.$target.first()[0].previousSibling; + var next = this.$target.last()[0].nextSibling; + var $parent = this.$target.parent(); + + var $clone = $('.oe_drop_clone'); + if (prev === $clone[0]) { + prev = $clone[0].previousSibling; + } else if (next === $clone[0]) { + next = $clone[0].nextSibling; + } + $clone.after(this.$target); + + this.$el.removeClass('d-none'); + $('body').removeClass('move-important'); + $clone.remove(); + + if (this.dropped) { + this.trigger_up('request_history_undo_record', {$target: this.$target}); + + if (prev) { + this.$target.insertAfter(prev); + } else if (next) { + this.$target.insertBefore(next); + } else { + $parent.prepend(this.$target); + } + + for (var i in this.styles) { + this.styles[i].onMove(); + } + } + + self.trigger_up('drag_and_drop_stop', { + $snippet: self.$target, + }); + }, + /** + * Called when a child editor/option asks for another option to perform a + * specific action/react to a specific event. + * + * @private + * @param {OdooEvent} ev + */ + _onOptionUpdate: function (ev) { + // If multiple option names are given, we suppose it should not be + // propagated to parent editor + if (ev.data.optionNames) { + ev.stopPropagation(); + var self = this; + _.each(ev.data.optionNames, function (name) { + var option = self.styles[name]; + if (option) { + option.notify(ev.data.name, ev.data.data); + } + }); + } + // If one option name is given, we suppose it should be handle by the + // first parent editor which can do it + if (ev.data.optionName) { + var option = this.styles[ev.data.optionName]; + if (option) { + ev.stopPropagation(); + option.notify(ev.data.name, ev.data.data); + } + } + }, + /** + * Called when the 'parent' button is clicked. + * + * @private + * @param {Event} ev + */ + _onParentButtonClick: function (ev) { + ev.preventDefault(); + this.trigger_up('go_to_parent', { + $snippet: this.$target, + }); + }, + /** + * Called when the 'remove' button is clicked. + * + * @private + * @param {Event} ev + */ + _onRemoveClick: function (ev) { + ev.preventDefault(); + this.trigger_up('request_history_undo_record', {$target: this.$target}); + this.removeSnippet(); + }, +}); + +/** + * Management of drag&drop menu and snippet related behaviors in the page. + */ +var SnippetsMenu = Widget.extend({ + id: 'oe_snippets', + activeSnippets: [], + custom_events: { + activate_insertion_zones: '_onActivateInsertionZones', + call_for_each_child_snippet: '_onCallForEachChildSnippet', + deactivate_snippet: '_onDeactivateSnippet', + drag_and_drop_stop: '_onDragAndDropStop', + go_to_parent: '_onGoToParent', + remove_snippet: '_onRemoveSnippet', + snippet_removed: '_onSnippetRemoved', + }, + + /** + * @constructor + */ + init: function (parent, $editable) { + this._super.apply(this, arguments); + + this.$editable = $editable; + this.$activeSnippet = false; + this.snippetEditors = []; + }, + /** + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + var $document = $(document); + var $window = $(window); + + // Fetch snippet templates and compute it + var url = this._getSnippetURL(); + defs.push(this._rpc({route: url}).then(function (html) { + return self._computeSnippetTemplates(html); + })); + + // Prepare snippets editor environment + this.$snippetEditorArea = $('<div/>', { + id: 'oe_manipulators', + }).insertAfter(this.$el); + + // Active snippet editor on click in the page + var lastClickedElement; + $document.on('click.snippets_menu', '*', function (ev) { + var srcElement = ev.srcElement || (ev.originalEvent && (ev.originalEvent.originalTarget || ev.originalEvent.target) || ev.target); + if (lastClickedElement === srcElement || !srcElement) { + return; + } + lastClickedElement = srcElement; + _.defer(function () { + lastClickedElement = false; + }); + + var $target = $(srcElement); + if ($target.closest('.oe_overlay, .note-popover').length) { + return; + } + self._activateSnippet($target); + }); + + core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet); + core.bus.on('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand); + + // Some summernote customization + var _isNotBreakable = $.summernote.core.dom.isNotBreakable; + $.summernote.core.dom.isNotBreakable = function (node) { + return _isNotBreakable(node) || $(node).is('div') || globalSelector.is($(node)); + }; + + // Adapt overlay covering when the window is resized / content changes + var debouncedCoverUpdate = _.debounce(function () { + self._updateCurrentSnippetEditorOverlay(); + }, 200); + $window.on('resize.snippets_menu', debouncedCoverUpdate); + $window.on('content_changed.snippets_menu', debouncedCoverUpdate); + + // On keydown add a class on the active overlay to hide it and show it + // again when the mouse moves + $document.on('keydown.snippets_menu', function () { + if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) { + self.$activeSnippet.data('snippet-editor').$el.addClass('o_keypress'); + } + }); + $document.on('mousemove.snippets_menu', function () { + if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) { + self.$activeSnippet.data('snippet-editor').$el.removeClass('o_keypress'); + } + }); + + // Auto-selects text elements with a specific class and remove this + // on text changes + $document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) { + $(ev.target).selectContent(); + }); + $document.on('keyup.snippets_menu', function () { + var r = $.summernote.core.range.create(); + $(r && r.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text'); + }); + + return $.when.apply($, defs).then(function () { + // Trigger a resize event once entering edit mode as the snippets + // menu will take part of the screen width (delayed because of + // animation). (TODO wait for real animation end) + setTimeout(function () { + $window.trigger('resize'); + }, 1000); + }); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$snippetEditorArea.remove(); + $(window).off('.snippets_menu'); + $(document).off('.snippets_menu'); + core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet); + core.bus.off('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Prepares the page so that it may be saved: + * - Asks the snippet editors to clean their associated snippet + * - Remove the 'contentEditable' attributes + */ + cleanForSave: function () { + this.trigger_up('ready_to_clean_for_save'); + this._destroyEditors(); + + this.$editable.find('[contentEditable]') + .removeAttr('contentEditable') + .removeProp('contentEditable'); + + this.$editable.find('.o_we_selected_image') + .removeClass('o_we_selected_image'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates drop zones in the DOM (locations where snippets may be dropped). + * Those locations are determined thanks to the two types of given DOM. + * + * @private + * @param {jQuery} [$selectorSiblings] + * elements which must have siblings drop zones + * @param {jQuery} [$selectorChildren] + * elements which must have child drop zones between each of existing + * child + */ + _activateInsertionZones: function ($selectorSiblings, $selectorChildren) { + var zone_template = $('<div/>', { + class: 'oe_drop_zone oe_insert', + }); + + function isFullWidth($elem) { + return $elem.parent().width() === $elem.outerWidth(true); + } + + if ($selectorChildren) { + $selectorChildren.each(function () { + var $zone = $(this); + var css = window.getComputedStyle(this); + var parentCss = window.getComputedStyle($zone.parent()[0]); + var float = css.float || css.cssFloat; + var parentDisplay = parentCss.display; + var parentFlex = parentCss.flexDirection; + var $drop = zone_template.clone(); + + $zone.append($drop); + var node = $drop[0].previousSibling; + var test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === 'BR')); + if (test) { + $drop.addClass('oe_vertical').css({ + height: parseInt(window.getComputedStyle($zone[0]).lineHeight), + float: 'none', + display: 'inline-block', + }); + } else if (float === 'left' || float === 'right' || (parentDisplay === 'flex' && parentFlex === 'row')) { + $drop.css('float', float); + if (!isFullWidth($zone)) { + $drop.addClass('oe_vertical').css('height', Math.max(Math.min($zone.outerHeight(), $zone.children().last().outerHeight()), 30)); + } + } + + $drop = $drop.clone(); + + $zone.prepend($drop); + node = $drop[0].nextSibling; + test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === 'BR')); + if (test) { + $drop.addClass('oe_vertical').css({ + height: parseInt(window.getComputedStyle($zone[0]).lineHeight), + float: 'none', + display: 'inline-block' + }); + } else if (float === 'left' || float === 'right' || (parentDisplay === 'flex' && parentFlex === 'row')) { + $drop.css('float', float); + if (!isFullWidth($zone)) { + $drop.addClass('oe_vertical').css('height', Math.max(Math.min($zone.outerHeight(), $zone.children().first().outerHeight()), 30)); + } + } + if (test) { + $drop.css({'float': 'none', 'display': 'inline-block'}); + } + }); + + // add children near drop zone + $selectorSiblings = $(_.uniq(($selectorSiblings || $()).add($selectorChildren.children()).get())); + } + + if ($selectorSiblings) { + $selectorSiblings.filter(':not(.oe_drop_zone):not(.oe_drop_clone)').each(function () { + var $zone = $(this); + var $drop; + var css = window.getComputedStyle(this); + var parentCss = window.getComputedStyle($zone.parent()[0]); + var float = css.float || css.cssFloat; + var parentDisplay = parentCss.display; + var parentFlex = parentCss.flexDirection; + + if ($zone.prev('.oe_drop_zone:visible').length === 0) { + $drop = zone_template.clone(); + if (float === 'left' || float === 'right' || (parentDisplay === 'flex' && parentFlex === 'row')) { + $drop.css('float', float); + if (!isFullWidth($zone)) { + $drop.addClass('oe_vertical').css('height', Math.max(Math.min($zone.outerHeight(), $zone.prev().outerHeight() || Infinity), 30)); + } + } + $zone.before($drop); + } + if ($zone.next('.oe_drop_zone:visible').length === 0) { + $drop = zone_template.clone(); + if (float === 'left' || float === 'right' || (parentDisplay === 'flex' && parentFlex === 'row')) { + $drop.css('float', float); + if (!isFullWidth($zone)) { + $drop.addClass('oe_vertical').css('height', Math.max(Math.min($zone.outerHeight(), $zone.next().outerHeight() || Infinity), 30)); + } + } + $zone.after($drop); + } + }); + } + + var count; + var $zones; + do { + count = 0; + $zones = this.$editable.find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones + count += $zones.length; + $zones.remove(); + } while (count > 0); + + // Cleaning consecutive zone and up zones placed between floating or + // inline elements. We do not like these kind of zones. + $zones = this.$editable.find('.oe_drop_zone:not(.oe_vertical)'); + $zones.each(function () { + var zone = $(this); + var prev = zone.prev(); + var next = zone.next(); + // remove consecutive zone + if (prev.is('.oe_drop_zone') || next.is('.oe_drop_zone')) { + zone.remove(); + return; + } + var float_prev = prev.css('float') || 'none'; + var float_next = next.css('float') || 'none'; + var disp_prev = prev.css('display') || null; + var disp_next = next.css('display') || null; + if ((float_prev === 'left' || float_prev === 'right') + && (float_next === 'left' || float_next === 'right')) { + zone.remove(); + } else if (disp_prev !== null && disp_next !== null + && disp_prev.indexOf('inline') >= 0 && disp_next.indexOf('inline') >= 0) { + zone.remove(); + } + }); + }, + /** + * Disable the overlay editor of the active snippet and activate the new one + * if given. + * Note 1: if the snippet editor associated to the given snippet is not + * created yet, this method will create it. + * Note 2: if the given DOM element is not a snippet (no editor option), the + * first parent which is one is used instead. + * + * @param {jQuery|false} $snippet + * The DOM element whose editor need to be enabled. Only disable the + * current one if false is given. + * @returns {Deferred} (might be async when an editor must be created) + */ + _activateSnippet: function ($snippet) { + if ($snippet) { + if (!globalSelector.is($snippet)) { + $snippet = globalSelector.closest($snippet); + } + if (this.$activeSnippet && this.$activeSnippet[0] === $snippet[0]) { + return $.when(); + } + } + if (this.$activeSnippet) { + if (this.$activeSnippet.data('snippet-editor')) { + this.$activeSnippet.data('snippet-editor').toggleFocus(false); + } + this.$activeSnippet = false; + } + if ($snippet && $snippet.length) { + var self = this; + return this._createSnippetEditor($snippet).then(function () { + self.$activeSnippet = $snippet; + if (self.$activeSnippet.data('snippet-editor')) { + self.$activeSnippet.data('snippet-editor').toggleFocus(true); + } + }); + } + return $.when(); + }, + /** + * @private + */ + _destroyEditors: function () { + _.each(this.snippetEditors, function (snippetEditor) { + snippetEditor.destroy(); + }); + }, + /** + * Updates the cover dimensions of the current snippet editor. + * + * @private + */ + _updateCurrentSnippetEditorOverlay: function () { + if (this.$activeSnippet && this.$activeSnippet.data('snippet-editor')) { + this.$activeSnippet.data('snippet-editor').cover(); + } + }, + /** + * Calls a given callback 'on' the given snippet and all its child ones if + * any (DOM element with options). + * + * Note: the method creates the snippet editors if they do not exist yet. + * + * @private + * @param {jQuery} $snippet + * @param {function} callback + * Given two arguments: the snippet editor associated to the snippet + * being managed and the DOM element of this snippet. + * @returns {Deferred} (might be async if snippet editors need to be created + * and/or the callback is async) + */ + _callForEachChildSnippet: function ($snippet, callback) { + var self = this; + var defs = _.map($snippet.add(globalSelector.all($snippet)), function (el) { + var $snippet = $(el); + return self._createSnippetEditor($snippet).then(function (editor) { + if (editor) { + return callback.call(self, editor, $snippet); + } + }); + }); + return $.when.apply($, defs); + }, + /** + * Creates and returns a set of helper functions which can help finding + * snippets in the DOM which match some parameters (typically parameters + * given by a snippet option). The functions are: + * + * - `is`: to determine if a given DOM is a snippet that matches the + * parameters + * + * - `closest`: find closest parent (or itself) of a given DOM which is a + * snippet that matches the parameters + * + * - `all`: find all snippets in the DOM that match the parameters + * + * See implementation for function details. + * + * @private + * @param {string} include + * jQuery selector that DOM elements must match to be considered as + * potential snippet. + * @param {string} exclude + * jQuery selector that DOM elements must *not* match the be + * considered as potential snippet. + * @param {string|false} target + * jQuery selector that at least one child of a DOM element must + * match to that DOM element be considered as a potential snippet. + * @param {boolean} noCheck + * true if DOM elements which are technically not in an editable + * environment may be considered. + * @param {boolean} isChildren + * when the DOM elements must be in an editable environment to be + * considered (@see noCheck), this is true if the DOM elements' + * parent must also be in an editable environment to be considered. + */ + _computeSelectorFunctions : function (include, exclude, target, noCheck, isChildren) { + var self = this; + + // Convert the selector for elements to include into a list + var selectorList = _.compact(include.split(/\s*,\s*/)); + + // Convert the selector for elements to exclude into a list + var excludeList = _.compact(exclude.split(/\s*,\s*/)); + excludeList.push('.o_snippet_not_selectable'); + + // Prepare the condition that will be added to each subselector for + // elements to include: 'not the elements to exclude and only the + // editable ones if needed' + var selectorConditions = _.map(excludeList, function (exc) { + return ':not(' + exc + ')'; + }).join(''); + if (target) { + selectorConditions += ':has(' + target + ')'; + } + if (!noCheck) { + selectorConditions = ':o_editable' + selectorConditions; + } + + // (Re)join the subselectors + var selector =_.map(selectorList, function (s) { + return s + selectorConditions; + }).join(', '); + + // Prepare the functions + var functions = { + is: function ($from) { + return $from.is(selector); + }, + }; + if (noCheck) { + functions.closest = function ($from, parentNode) { + return $from.closest(selector, parentNode); + }; + functions.all = function ($from) { + return $from ? dom.cssFind($from, selector) : $(selector); + }; + } else { + functions.closest = function ($from, parentNode) { + var parents = self.$editable.get(); + return $from.closest(selector, parentNode).filter(function () { + var node = this; + while (node.parentNode) { + if (parents.indexOf(node)!==-1) { + return true; + } + node = node.parentNode; + } + return false; + }); + }; + functions.all = isChildren ? function ($from) { + return dom.cssFind($from || self.$editable, selector); + } : function ($from) { + $from = $from || self.$editable; + return $from.filter(selector).add(dom.cssFind($from, selector)); + }; + } + return functions; + }, + /** + * Processes the given snippet template to register snippet options, creates + * draggable thumbnail, etc. + * + * @private + * @param {string} html + */ + _computeSnippetTemplates: function (html) { + var self = this; + var $html = $(html); + var $scroll = $html.siblings('#o_scroll'); + + $html.find('[data-oe-type="snippet"]').each(function () { + $(this).children() + .attr('data-oe-type', 'snippet') + .attr('data-oe-thumbnail', $(this).data('oe-thumbnail')); + }); + + this.templateOptions = []; + var selectors = []; + var $styles = $html.find('[data-selector]'); + $styles.each(function () { + var $style = $(this); + var selector = $style.data('selector'); + var exclude = $style.data('exclude') || ''; + var target = $style.data('target'); + var noCheck = $style.data('no-check'); + var option_id = $style.data('js'); + var option = { + 'option': option_id, + 'base_selector': selector, + 'base_exclude': exclude, + 'base_target': target, + 'selector': self._computeSelectorFunctions(selector, exclude, target, noCheck), + '$el': $style, + 'drop-near': $style.data('drop-near') && self._computeSelectorFunctions($style.data('drop-near'), '', false, noCheck, true), + 'drop-in': $style.data('drop-in') && self._computeSelectorFunctions($style.data('drop-in'), '', false, noCheck), + 'data': $style.data(), + }; + self.templateOptions.push(option); + selectors.push(option.selector); + }); + $styles.addClass('d-none'); + + globalSelector.closest = function ($from) { + var $temp; + var $target; + for (var i = 0, len = selectors.length ; i < len ; i++) { + $temp = selectors[i].closest($from, $target && $target[0]); + if ($temp.length) { + $target = $temp; + } + } + return $target || $(); + }; + globalSelector.all = function ($from) { + var $target = $(); + for (var i = 0, len = selectors.length ; i < len ; i++) { + $target = $target.add(selectors[i].all($from)); + } + return $target; + }; + globalSelector.is = function ($from) { + for (var i = 0, len = selectors.length ; i < len ; i++) { + if (selectors[i].is($from)) { + return true; + } + } + return false; + }; + + this.$snippets = $scroll.find('.o_panel_body').children() + .addClass('oe_snippet') + .each(function () { + var $snippet = $(this); + var name = $snippet.attr('name'); + var $sbody = $snippet.children(':not(.oe_snippet_thumbnail)').addClass('oe_snippet_body'); + + // Associate in-page snippets to their name + if ($sbody.length) { + var snippetClasses = $sbody.attr('class').match(/s_[^ ]+/g); + if (snippetClasses && snippetClasses.length) { + snippetClasses = '.' + snippetClasses.join('.'); + } + $(snippetClasses).data('name', name); + $sbody.data('name', name); + } + + // Create the thumbnail + if ($snippet.find('.oe_snippet_thumbnail').length) { + return; // Compatibility with elements which do not use 't-snippet' + } + var $thumbnail = $(_.str.sprintf( + '<div class="oe_snippet_thumbnail">'+ + '<div class="oe_snippet_thumbnail_img" style="background-image: url(%s);"/>'+ + '<span class="oe_snippet_thumbnail_title">%s</span>'+ + '</div>', + $snippet.find('[data-oe-thumbnail]').data('oeThumbnail'), + name + )); + $snippet.prepend($thumbnail); + + // Create the install button (t-install feature) if necessary + var moduleID = $snippet.data('moduleId'); + if (moduleID) { + $snippet.addClass('o_snippet_install'); + var $installBtn = $('<a/>', { + class: 'btn btn-primary o_install_btn', + target: '_blank', + href: '/web#id=' + moduleID + '&view_type=form&model=ir.module.module&action=base.open_module_tree', + text: _t("Install"), + }); + $thumbnail.append($installBtn); + } + }) + .not('[data-module-id]'); + + // Hide scroll if no snippets defined + if (!this.$snippets.length) { + this.$el.detach(); + } + $('body').toggleClass('editor_has_snippets', this.$snippets.length > 0); + + // Register the text nodes that needs to be auto-selected on click + this._registerDefaultTexts(); + + // Remove branding from template + _.each($html.find('[data-oe-model], [data-oe-type]'), function (el) { + for (var k = 0 ; k < el.attributes.length ; k++) { + if (el.attributes[k].name.indexOf('data-oe-') === 0) { + $(el).removeAttr(el.attributes[k].name); + k--; + } + } + }); + + // Force non editable part to contentEditable=false + $html.find('.o_not_editable').attr('contentEditable', false); + + // Add the computed template and make elements draggable + this.$el.html($html); + this._makeSnippetDraggable(this.$snippets); + this._disableUndroppableSnippets(); + }, + /** + * Creates a snippet editor to associated to the given snippet. If the given + * snippet already has a linked snippet editor, the function only returns + * that one. + * The function also instantiates a snippet editor for all snippet parents + * as a snippet editor must be able to display the parent snippet options. + * + * @private + * @param {jQuery} $snippet + * @returns {Deferred<SnippetEditor>} + */ + _createSnippetEditor: function ($snippet) { + var self = this; + var snippetEditor = $snippet.data('snippet-editor'); + if (snippetEditor) { + return $.when(snippetEditor); + } + + var def; + var $parent = globalSelector.closest($snippet.parent()); + if ($parent.length) { + def = this._createSnippetEditor($parent); + } + + return $.when(def).then(function (parentEditor) { + snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions); + self.snippetEditors.push(snippetEditor); + return snippetEditor.appendTo(self.$snippetEditorArea); + }).then(function () { + return snippetEditor; + }); + }, + /** + * There may be no location where some snippets might be dropped. This mades + * them appear disabled in the menu. + * + * @todo make them undraggable + * @private + */ + _disableUndroppableSnippets: function () { + var self = this; + var cache = {}; + this.$snippets.each(function () { + var $snippet = $(this); + var $snippet_body = $snippet.find('.oe_snippet_body'); + + var check = false; + _.each(self.templateOptions, function (option, k) { + if (check || !($snippet_body.is(option.base_selector) && !$snippet_body.is(option.base_exclude))) return; + + cache[k] = cache[k] || { + 'drop-near': option['drop-near'] ? option['drop-near'].all().length : 0, + 'drop-in': option['drop-in'] ? option['drop-in'].all().length : 0 + }; + check = (cache[k]['drop-near'] || cache[k]['drop-in']); + }); + + $snippet.toggleClass('o_disabled', !check); + }); + }, + /** + * Returns the URL where to find the snippets template. This URL might have + * been set in the global 'snippetsURL' variable, otherwise this function + * returns a default one. + * + * @private + * @returns {string} + */ + _getSnippetURL: function () { + return odoo.snippetsURL || '/web_editor/snippets'; + }, + /** + * Make given snippets be draggable/droppable thanks to their thumbnail. + * + * @private + * @param {jQuery} $snippets + */ + _makeSnippetDraggable: function ($snippets) { + var self = this; + var $tumb = $snippets.find('.oe_snippet_thumbnail_img:first'); + var left = $tumb.outerWidth()/2; + var top = $tumb.outerHeight()/2; + var $toInsert, dropped, $snippet; + + $snippets.draggable({ + greedy: true, + helper: 'clone', + zIndex: '1000', + appendTo: 'body', + cursor: 'move', + handle: '.oe_snippet_thumbnail', + distance: 30, + cursorAt: { + left: left, + top: top, + }, + start: function () { + dropped = false; + $snippet = $(this); + var $base_body = $snippet.find('.oe_snippet_body'); + var $selectorSiblings = $(); + var $selectorChildren = $(); + var temp = self.templateOptions; + for (var k in temp) { + if ($base_body.is(temp[k].base_selector) && !$base_body.is(temp[k].base_exclude)) { + if (temp[k]['drop-near']) { + if (!$selectorSiblings) $selectorSiblings = temp[k]['drop-near'].all(); + else $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all()); + } + if (temp[k]['drop-in']) { + if (!$selectorChildren) $selectorChildren = temp[k]['drop-in'].all(); + else $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all()); + } + } + } + + $toInsert = $base_body.clone().data('name', $base_body.data('name')); + + if (!$selectorSiblings.length && !$selectorChildren.length) { + console.warn($snippet.find('.oe_snippet_thumbnail_title').text() + " have not insert action: data-drop-near or data-drop-in"); + return; + } + + self._activateSnippet(false); + self._activateInsertionZones($selectorSiblings, $selectorChildren); + + $('.oe_drop_zone').droppable({ + over: function () { + if (!dropped) { + dropped = true; + $(this).first().after($toInsert).addClass('d-none'); + } + }, + out: function () { + var prev = $toInsert.prev(); + if (this === prev[0]) { + dropped = false; + $toInsert.detach(); + $(this).removeClass('d-none'); + } + }, + }); + }, + stop: function (ev, ui) { + $toInsert.removeClass('oe_snippet_body'); + + if (!dropped && ui.position.top > 3 && ui.position.left + 50 > self.$el.outerWidth()) { + var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone').first(); + if ($el.length) { + $el.after($toInsert); + dropped = true; + } + } + + self.$editable.find('.oe_drop_zone').droppable('destroy').remove(); + + if (dropped) { + var prev = $toInsert.first()[0].previousSibling; + var next = $toInsert.last()[0].nextSibling; + + if (prev) { + $toInsert.detach(); + self.trigger_up('request_history_undo_record', {$target: $(prev)}); + $toInsert.insertAfter(prev); + } else if (next) { + $toInsert.detach(); + self.trigger_up('request_history_undo_record', {$target: $(next)}); + $toInsert.insertBefore(next); + } else { + var $parent = $toInsert.parent(); + $toInsert.detach(); + self.trigger_up('request_history_undo_record', {$target: $parent}); + $parent.prepend($toInsert); + } + + $toInsert.closest('.o_editable').trigger('content_changed'); + + var $target = $toInsert; + + _.defer(function () { + self.trigger_up('snippet_dropped', {$target: $target}); + self._disableUndroppableSnippets(); + + self._callForEachChildSnippet($target, function (editor, $snippet) { + _.defer(function () { + editor.buildSnippet(); + }); + }).then(function () { + $target.closest('.o_editable').trigger('content_changed'); + self._activateSnippet($target); + }); + }); + } else { + $toInsert.remove(); + } + }, + }); + }, + /** + * Adds the 'o_default_snippet_text' class on nodes which contain only + * non-empty text nodes. Those nodes are then auto-selected by the editor + * when they are clicked. + * + * @private + * @param {jQuery} [$in] - the element in which to search, default to the + * snippet bodies in the menu + */ + _registerDefaultTexts: function ($in) { + if ($in === undefined) { + $in = this.$snippets.find('.oe_snippet_body'); + } + + $in.find('*').addBack() + .contents() + .filter(function () { + return this.nodeType === 3 && this.textContent.match(/\S/); + }).parent().addClass('o_default_snippet_text'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a child editor asks for insertion zones to be enabled. + * + * @private + * @param {OdooEvent} ev + */ + _onActivateInsertionZones: function (ev) { + this._activateInsertionZones(ev.data.$selectorSiblings, ev.data.$selectorChildren); + }, + /** + * Called when a child editor asks to operate some operation on all child + * snippet of a DOM element. + * + * @private + * @param {OdooEvent} ev + */ + _onCallForEachChildSnippet: function (ev) { + this._callForEachChildSnippet(ev.data.$snippet, ev.data.callback); + }, + /** + * Called when asked to clean the DOM for save. Should technically not be + * used but used by tests. + * + * @private + */ + _onCleanForSaveDemand: function (ev) { + this.cleanForSave(); + }, + /** + * Called when a child editor asks to deactivate the current snippet + * overlay. + * + * @private + */ + _onDeactivateSnippet: function () { + this._activateSnippet(false); + }, + /** + * Called when a snippet has moved in the page. + * + * @private + * @param {OdooEvent} ev + */ + _onDragAndDropStop: function (ev) { + this._destroyEditors(); + this._activateSnippet(ev.data.$snippet); + }, + /** + * Called when a snippet editor asked to disable itself and to enable its + * parent instead. + * + * @private + * @param {OdooEvent} ev + */ + _onGoToParent: function (ev) { + ev.stopPropagation(); + this._activateSnippet(ev.data.$snippet.parent()); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onRemoveSnippet: function (ev) { + ev.stopPropagation(); + this._createSnippetEditor(ev.data.$snippet).then(function (editor) { + editor.removeSnippet(); + }); + }, + /** + * Called when a snippet is removed -> checks if there is draggable snippets + * to enable/disable as the DOM changed. + * + * @private + */ + _onSnippetRemoved: function () { + this._disableUndroppableSnippets(); + }, +}); + +return { + Class: SnippetsMenu, + Editor: SnippetEditor, + globalSelector: globalSelector, +}; +}); diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js new file mode 100644 index 00000000000..f481061c9e2 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -0,0 +1,1331 @@ +odoo.define('web_editor.snippets.options', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); +var weWidgets = require('web_editor.widget'); + +var qweb = core.qweb; +var _t = core._t; + +/** + * Handles a set of options for one snippet. The registry returned by this + * module contains the names of the specialized SnippetOption which can be + * referenced thanks to the data-js key in the web_editor options template. + */ +var SnippetOption = Widget.extend({ + events: { + 'mouseenter': '_onLinkEnter', + 'mouseenter .dropdown-item': '_onLinkEnter', + 'click': '_onLinkClick', + 'mouseleave': '_onMouseleave', + 'mouseleave .dropdown-item': '_onMouseleave', + }, + /** + * When editing a snippet, its options are shown alongside the ones of its + * parent snippets. The parent options are only shown if the following flag + * is set to false (default). + */ + preventChildPropagation: false, + + /** + * The option `$el` is supposed to be the associated DOM element in the + * options dropdown. The option controls another DOM element: the snippet it + * customizes, which can be found at `$target`. Access to the whole edition + * overlay is possible with `$overlay` (this is not recommended though). + * + * @constructor + */ + init: function (parent, $target, $overlay, data) { + this._super.apply(this, arguments); + this.$target = $target; + this.$overlay = $overlay; + this.data = data; + this.__methodNames = []; + }, + /** + * Called when the option is initialized (i.e. the parent edition overlay is + * shown for the first time). + * + * @override + */ + start: function () { + this._setActive(); + return this._super.apply(this, arguments); + }, + /** + * Called when the parent edition overlay is covering the associated snippet + * (the first time, this follows the call to the @see start method). + * + * @abstract + */ + onFocus : function () {}, + /** + * Called when the parent edition overlay is covering the associated snippet + * for the first time, when it is a new snippet dropped from the d&d snippet + * menu. Note: this is called after the start and onFocus methods. + * + * @abstract + */ + onBuilt: function () {}, + /** + * Called when the parent edition overlay is removed from the associated + * snippet (another snippet enters edition for example). + * + * @abstract + */ + onBlur : function () {}, + /** + * Called when the associated snippet is the result of the cloning of + * another snippet (so `this.$target` is a cloned element). + * + * @abstract + * @param {Object} options + * @param {boolean} options.isCurrent + * true if the associated snippet is a clone of the main element that + * was cloned (so not a clone of a child of this main element that + * was cloned) + */ + onClone: function (options) {}, + /** + * Called when the associated snippet is moved to another DOM location. + * + * @abstract + */ + onMove: function () {}, + /** + * Called when the associated snippet is about to be removed from the DOM. + * + * @abstract + */ + onRemove: function () {}, + /** + * Called when the template which contains the associated snippet is about + * to be saved. + * + * @abstract + */ + cleanForSave: function () {}, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Default option method which allows to select one and only one class in + * the option classes set and set it on the associated snippet. The common + * case is having a subdropdown with each item having a `data-select-class` + * value allowing to choose the associated class. + * + * @param {boolean|string} previewMode + * - truthy if the option is enabled for preview or if leaving it (in + * that second case, the value is 'reset') + * - false if the option should be activated for good + * @param {*} value - the class to activate ($opt.data('selectClass')) + * @param {jQuery} $opt - the related DOMElement option + */ + selectClass: function (previewMode, value, $opt) { + var $group = $opt && $opt.parents('.dropdown-submenu').last(); + if (!$group || !$group.length) { + $group = this.$el; + } + var $lis = $group.find('[data-select-class]').addBack('[data-select-class]'); + var classes = $lis.map(function () {return $(this).data('selectClass');}).get().join(' '); + + this.$target.removeClass(classes); + if (value) { + this.$target.addClass(value); + } + }, + /** + * Default option method which allows to select one or multiple classes in + * the option classes set and set it on the associated snippet. The common + * case is having a subdropdown with each item having a `data-toggle-class` + * value allowing to toggle the associated class. + * + * @see this.selectClass + */ + toggleClass: function (previewMode, value, $opt) { + var $lis = this.$el.find('[data-toggle-class]').addBack('[data-toggle-class]'); + var classes = $lis.map(function () {return $(this).data('toggleClass');}).get().join(' '); + var activeClasses = $lis.filter('.active, :has(.active)').map(function () {return $(this).data('toggleClass');}).get().join(' '); + + this.$target.removeClass(classes).addClass(activeClasses); + if (value && previewMode !== 'reset') { + this.$target.toggleClass(value); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Override the helper method to search inside the $target element instead + * of the dropdown item element. + * + * @override + */ + $: function () { + return this.$target.find.apply(this.$target, arguments); + }, + /** + * Sometimes, options may need to notify other options, even in parent + * editors. This can be done thanks to the 'option_update' event, which + * will then be handled by this function. + * + * @param {string} name - an identifier for a type of update + * @param {*} data + */ + notify: function (name, data) { + if (name === 'target') { + this.setTarget(data); + } + }, + /** + * Sometimes, an option is binded on an element but should in fact apply on + * another one. For example, elements which contain slides: we want all the + * per-slide options to be in the main menu of the whole snippet. This + * function allows to set the option's target. + * + * @param {jQuery} $target - the new target element + */ + setTarget: function ($target) { + this.$target = $target; + this._setActive(); + this.$target.trigger('snippet-option-change', [this]); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Reactivate the options that were activated before previews. + */ + _reset: function () { + var self = this; + var $actives = this.$el.find('.active').addBack('.active'); + _.each($actives, function (activeElement) { + var $activeElement = $(activeElement); + self.__methodNames = _.without.apply(_, [self.__methodNames].concat(_.keys($activeElement.data()))); + self._select('reset', $activeElement); + }); + _.each(this.__methodNames, function (methodName) { + self[methodName]('reset'); + }); + this.__methodNames = []; + }, + /** + * Activates the option associated to the given DOM element. + * + * @private + * @param {boolean|string} previewMode + * - truthy if the option is enabled for preview or if leaving it (in + * that second case, the value is 'reset') + * - false if the option should be activated for good + * @param {jQuery} $opt - the related DOMElement option + */ + _select: function (previewMode, $opt) { + var self = this; + + // Options can say they respond to strong choice + if (previewMode && ($opt.data('noPreview') || $opt.parent().data('noPreview'))) { + return; + } + // If it is not preview mode, the user selected the option for good + // (so record the action) + if (!previewMode) { + this._reset(); + this.trigger_up('request_history_undo_record', {$target: this.$target}); + } + + // Search for methods (data-...) (i.e. data-toggle-class) on the + // selected (sub)option and its parents + var el = $opt[0]; + var methods = []; + do { + methods.push([el, el.dataset]); + el = el.parentNode; + } while (this.$el.parent().has(el).length); + + // Call the found method in the right order (parents -> child) + _.each(methods.reverse(), function (data) { + var $el = $(data[0]); + var methods = data[1]; + + _.each(methods, function (value, methodName) { + if (self[methodName]) { + if (previewMode === true) { + self.__methodNames.push(methodName); + } + self[methodName](previewMode, value, $el); + } + }); + }); + this.__methodNames = _.uniq(this.__methodNames); + + if (!previewMode) { + this._setActive(); + } + }, + /** + * Tweaks the option DOM elements to show the selected value according to + * the state of the $target the option customizes. + * + * @todo should be extendable in a more easy way + * @private + */ + _setActive: function () { + var self = this; + this.$el.find('[data-toggle-class]') + .addBack('[data-toggle-class]') + .removeClass('active') + .filter(function () { + var className = $(this).data('toggleClass'); + return !className || self.$target.hasClass(className); + }) + .addClass('active'); + + // Get submenus which are not inside submenus + var $submenus = this.$el.find('.dropdown-submenu') + .addBack('.dropdown-submenu') + .not('.dropdown-submenu .dropdown-submenu'); + + // Add unique active class for each submenu active item + _.each($submenus, function (submenu) { + var $elements = _getSelectClassElements($(submenu)); + _processSelectClassElements($elements); + }); + + // Add unique active class for out-of-submenu active item + var $externalElements = _getSelectClassElements(this.$el) + .not('.dropdown-submenu *, .dropdown-submenu'); + _processSelectClassElements($externalElements); + + function _getSelectClassElements($el) { + return $el.find('[data-select-class]') + .addBack('[data-select-class]'); + } + function _processSelectClassElements($elements) { + var maxNbClasses = -1; + $elements.removeClass('active') + .filter(function () { + var className = $(this).data('selectClass'); + var nbClasses = className ? className.split(' ').length : 0; + if (nbClasses >= maxNbClasses && (!className || self.$target.hasClass(className))) { + maxNbClasses = nbClasses; + return true; + } + return false; + }) + .last() + .addClass('active'); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a option link is entered -> activates the related option in + * preview mode. + * + * @private + * @param {Event} ev + */ + _onLinkEnter: function (ev) { + var $opt = $(ev.target).closest('.dropdown-item'); + if (!$opt.length) { + return; + } + + var $dsmenu = $opt.parent('.dropdown-submenu'); + if ($dsmenu.length) { + var $menu = $dsmenu.children('.dropdown-menu'); // FIXME + if ($menu.length) { + var menuRightPosition = $dsmenu.offset().left + $dsmenu.outerWidth() + $menu.outerWidth(); + $menu.toggleClass('o_open_to_left', menuRightPosition > $(window).outerWidth()); + } + } + + if (!$opt.is(':hasData')) { + return; + } + this.__click = false; + this._select(true, $opt); + this.$target.trigger('snippet-option-preview', [this]); + }, + /** + * Called when an option link is clicked -> activates the related option. + * + * @private + * @param {Event} ev + */ + _onLinkClick: function (ev) { + var $opt = $(ev.target).closest('.dropdown-item'); + if (!$opt.length || !$opt.is(':hasData')) { + return; + } + + ev.preventDefault(); + this.__click = true; + this._select(false, $opt); + this.$target.trigger('snippet-option-change', [this]); + }, + /** + * Called when an option link/menu is left -> reactivate the options that + * were activated before previews. + * + * @private + */ + _onMouseleave: function () { + if (this.__click) { + return; + } + this._reset(); + }, +}); + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +/** + * The registry object contains the list of available options. + */ +var registry = {}; + +registry.sizing = SnippetOption.extend({ + preventChildPropagation: true, + + /** + * @override + */ + start: function () { + var self = this; + var def = this._super.apply(this, arguments); + + this.$handles = this.$overlay.find('.o_handle'); + var resizeValues = this._getSize(); + _.each(resizeValues, function (value, key) { + self.$handles.filter('.' + key).toggleClass('readonly', !value); + }); + + this.$handles.on('mousedown', function (ev) { + ev.preventDefault(); + + var $handle = $(ev.currentTarget); + + var compass = false; + var XY = false; + if ($handle.hasClass('n')) { + compass = 'n'; + XY = 'Y'; + } else if ($handle.hasClass('s')) { + compass = 's'; + XY = 'Y'; + } else if ($handle.hasClass('e')) { + compass = 'e'; + XY = 'X'; + } else if ($handle.hasClass('w')) { + compass = 'w'; + XY = 'X'; + } + + var resize = resizeValues[compass]; + if (!resize) { + return; + } + + var current = 0; + var cssProperty = resize[2]; + var cssPropertyValue = parseInt(self.$target.css(cssProperty)); + _.each(resize[0], function (val, key) { + if (self.$target.hasClass(val)) { + current = key; + } else if (resize[1][key] === cssPropertyValue) { + current = key; + } + }); + var begin = current; + var beginClass = self.$target.attr('class'); + var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); + + var cursor = $handle.css('cursor') + '-important'; + var $body = $(document.body); + $body.addClass(cursor); + + var xy = ev['page' + XY]; + var body_mousemove = function (ev) { + ev.preventDefault(); + + var dd = ev['page' + XY] - xy + resize[1][begin]; + var next = current + (current+1 === resize[1].length ? 0 : 1); + var prev = current ? (current-1) : 0; + + var change = false; + if (dd > (2*resize[1][next] + resize[1][current])/3) { + self.$target.attr('class', (self.$target.attr('class')||'').replace(regClass, '')); + self.$target.addClass(resize[0][next]); + current = next; + change = true; + } + if (prev !== current && dd < (2*resize[1][prev] + resize[1][current])/3) { + self.$target.attr('class', (self.$target.attr('class')||'').replace(regClass, '')); + self.$target.addClass(resize[0][prev]); + current = prev; + change = true; + } + + if (change) { + self._onResize(compass, beginClass, current); + self.trigger_up('cover_update'); + $handle.addClass('o_active'); + } + }; + var body_mouseup = function () { + $body.off('mousemove', body_mousemove); + $body.off('mouseup', body_mouseup); + $body.removeClass(cursor); + $handle.removeClass('o_active'); + + // Highlights the previews for a while + var $handlers = self.$overlay.find('.o_handle'); + $handlers.addClass('o_active').delay(300).queue(function () { + $handlers.removeClass('o_active').dequeue(); + }); + + if (begin === current) { + return; + } + setTimeout(function () { + self.trigger_up('request_history_undo_record', { + $target: self.$target, + event: 'resize_' + XY, + }); + }, 0); + }; + $body.on('mousemove', body_mousemove); + $body.on('mouseup', body_mouseup); + }); + + return def; + }, + /** + * @override + */ + onFocus: function () { + this._onResize(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + setTarget: function () { + this._super.apply(this, arguments); + this._onResize(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns an object mapping one or several cardinal direction (n, e, s, w) + * to an Array containing: + * 1) A list of classes to toggle when using this cardinal direction + * 2) A list of values these classes are supposed to set on a given CSS prop + * 3) The mentioned CSS prop + * + * Note: this object must also be saved in this.grid before being returned. + * + * @abstract + * @private + * @returns {Object} + */ + _getSize: function () {}, + /** + * Called when the snippet is being resized and its classes changes. + * + * @private + * @param {string} [compass] - resize direction ('n', 's', 'e' or 'w') + * @param {string} [beginClass] - attributes class at the beginning + * @param {integer} [current] - current increment in this.grid + */ + _onResize: function (compass, beginClass, current) { + var self = this; + + // Adapt the resize handles according to the classes and dimensions + var resizeValues = this._getSize(); + var $handles = this.$overlay.find('.o_handle'); + _.each(resizeValues, function (resizeValue, direction) { + var classes = resizeValue[0]; + var values = resizeValue[1]; + var cssProperty = resizeValue[2]; + + var $handle = $handles.filter('.' + direction); + + var current = 0; + var cssPropertyValue = parseInt(self.$target.css(cssProperty)); + _.each(classes, function (className, key) { + if (self.$target.hasClass(className)) { + current = key; + } else if (values[key] === cssPropertyValue) { + current = key; + } + }); + + $handle.toggleClass('o_handle_start', current === 0); + $handle.toggleClass('o_handle_end', current === classes.length - 1); + }); + + // Adapt the handles to fit the left, top and bottom sizes + var ml = this.$target.css('margin-left'); + this.$overlay.find('.o_handle.w').css({ + width: ml, + left: '-' + ml, + }); + _.each(this.$overlay.find(".o_handle.n, .o_handle.s"), function (handle) { + var $handle = $(handle); + var direction = $handle.hasClass('n') ? 'top': 'bottom'; + $handle.height(self.$target.css('padding-' + direction)); + }); + }, +}); + +/** + * Handles the edition of padding-top and padding-bottom. + */ +registry.sizing_y = registry.sizing.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + var nClass = 'pt'; + var nProp = 'padding-top'; + var sClass = 'pb'; + var sProp = 'padding-bottom'; + if (this.$target.is('hr')) { + nClass = 'mt'; + nProp = 'margin-top'; + sClass = 'mb'; + sProp = 'margin-bottom'; + } + + var grid = []; + for (var i = 0 ; i <= 256/8 ; i++) { + grid.push(i * 8); + } + grid.splice(1, 0, 4); + this.grid = { + n: [_.map(grid, function (v) { return nClass + v; }), grid, nProp], + s: [_.map(grid, function (v) { return sClass + v; }), grid, sProp], + }; + return this.grid; + }, +}); + +/** + * Handles the edition of snippet's background color classes. + */ +registry.colorpicker = SnippetOption.extend({ + xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], + events: _.extend({}, SnippetOption.prototype.events || {}, { + 'click .colorpicker button': '_onColorButtonClick', + 'mouseenter .colorpicker button': '_onColorButtonEnter', + 'mouseleave .colorpicker button': '_onColorButtonLeave', + 'click .note-color-reset': '_onColorResetButtonClick', + }), + colorPrefix: 'bg-', + + /** + * @override + */ + start: function () { + var self = this; + var res = this._super.apply(this, arguments); + + if (this.data.colorPrefix) { + this.colorPrefix = this.data.colorPrefix; + } + + if (!this.$el.find('.colorpicker').length) { + // TODO remove old UI's code that survived + var $pt = $(qweb.render('web_editor.snippet.option.colorpicker')); + var $clpicker = $(qweb.render('web_editor.colorpicker')); + + _.each($clpicker.find('.o_colorpicker_section'), function (elem) { + $(elem).prepend("<div class='text-muted mt8'>" + elem.dataset.display + "</div>"); + }); + + // Retrieve excluded palettes list + var excluded = []; + if (this.data.paletteExclude) { + excluded = this.data.paletteExclude.replace(/ /g, '').split(','); + } + // Apply a custom title if specified + if (this.data.paletteTitle) { + $pt.find('.note-palette-title').text(this.data.paletteTitle); + } + + // Remove excluded palettes + _.each(excluded, function (exc) { + $clpicker.find('[data-name="' + exc + '"]').remove(); + }); + + // Add common colors to palettes if not excluded + if (!('common' in excluded)) { + var $commonColorSection = $clpicker.find('[data-name="common"]'); + _.each(summernoteCustomColors, function (colorRow, i) { + var $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection); + if (i === 0) { + // Ignore the summernote gray palette and use ours + return; + } + _.each(colorRow, function (color) { + $div.append('<button class="o_custom_color" style="background-color: ' + color + '" />'); + }); + }); + } + + $pt.find('.o_colorpicker_section_tabs').append($clpicker); + this.$el.find('.dropdown-menu').append($pt); + } + + var classes = []; + this.$el.find('.colorpicker button').each(function () { + var $color = $(this); + var color = $color.data('color'); + if (!color) { + return; + } + + $color.addClass('bg-' + color); + var className = self.colorPrefix + color; + if (self.$target.hasClass(className)) { + $color.addClass('selected'); + } + classes.push(className); + }); + this.classes = classes.join(' '); + + return res; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a color button is clicked -> confirm the preview. + * + * @private + * @param {Event} ev + */ + _onColorButtonClick: function (ev) { + this.$el.find('.colorpicker button.selected').removeClass('selected'); + $(ev.currentTarget).addClass('selected'); + this.$target.closest('.o_editable').trigger('content_changed'); + this.$target.trigger('background-color-event', false); + }, + /** + * Called when a color button is entered -> preview the background color. + * + * @private + * @param {Event} ev + */ + _onColorButtonEnter: function (ev) { + this.$target.removeClass(this.classes); + var color = $(ev.currentTarget).data('color'); + if (color) { + this.$target.addClass(this.colorPrefix + color); + } else if ($(ev.target).hasClass('o_custom_color')) { + this.$target + .removeClass(this.classes) + .css('background-color', ev.currentTarget.style.backgroundColor); + } + this.$target.trigger('background-color-event', true); + }, + /** + * Called when a color button is left -> cancel the preview. + * + * @private + * @param {Event} ev + */ + _onColorButtonLeave: function (ev) { + this.$target.removeClass(this.classes); + this.$target.css('background-color', ''); + var $selected = this.$el.find('.colorpicker button.selected'); + if ($selected.length) { + if ($selected.data('color')) { + this.$target.addClass(this.colorPrefix + $selected.data('color')); + } else { + this.$target.css('background-color', $selected.css('background-color')); + } + } + this.$target.trigger('background-color-event', 'reset'); + }, + /** + * Called when the color reset button is clicked -> remove all background + * color classes. + * + * @private + */ + _onColorResetButtonClick: function () { + this.$target.removeClass(this.classes).css('background-color', ''); + this.$target.trigger('content_changed'); + this.$el.find('.colorpicker button.selected').removeClass('selected'); + }, +}); + +/** + * Handles the edition of snippet's background image. + */ +registry.background = SnippetOption.extend({ + /** + * @override + */ + start: function () { + var res = this._super.apply(this, arguments); + this.bindBackgroundEvents(); + this.__customImageSrc = this._getSrcFromCssValue(); + return res; + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles a background change. + * + * @see this.selectClass for parameters + */ + background: function (previewMode, value, $opt) { + if (previewMode === 'reset' && value === undefined) { + // No background has been selected and we want to reset back to the + // original custom image + this._setCustomBackground(this.__customImageSrc); + return; + } + + if (value && value.length) { + this.$target.css('background-image', 'url(\'' + value + '\')'); + this.$target.removeClass('oe_custom_bg').addClass('oe_img_bg'); + } else { + this.$target.css('background-image', ''); + this.$target.removeClass('oe_img_bg oe_custom_bg'); + } + }, + /** + * @override + */ + selectClass: function (previewMode, value, $opt) { + this.background(previewMode, '', $opt); + this._super(previewMode, value ? (value + ' oe_img_bg') : value, $opt); + }, + /** + * Opens a media dialog to add a custom background image. + * + * @see this.selectClass for parameters + */ + chooseImage: function (previewMode, value, $opt) { + // Put fake image in the DOM, edit it and use it as background-image + var $image = $('<img/>', {class: 'd-none', src: value}).appendTo(this.$target); + + var $editable = this.$target.closest('.o_editable'); + var _editor = new weWidgets.MediaDialog(this, { + onlyImages: true, + firstFilters: ['background'], + res_model: $editable.data('oe-model'), + res_id: $editable.data('oe-id'), + }, null, $image[0]).open(); + + _editor.on('save', this, function () { + this._setCustomBackground($image.attr('src')); + }); + _editor.on('closed', this, function () { + $image.remove(); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Attaches events so that when a background-color is set, the background + * image is removed. + */ + bindBackgroundEvents: function () { + if (this.$target.is('.parallax, .s_parallax_bg')) { + return; + } + this.$target.off('.background-option') + .on('background-color-event.background-option', (function (e, previewMode) { + e.stopPropagation(); + if (e.currentTarget !== e.target) return; + if (previewMode === false) { + this.__customImageSrc = undefined; + } + this.background(previewMode); + }).bind(this)); + }, + /** + * @override + */ + setTarget: function () { + this._super.apply(this, arguments); + // TODO should be automatic for all options as equal to the start method + this.bindBackgroundEvents(); + this.__customImageSrc = this._getSrcFromCssValue(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the src value from a css value related to a background image + * (e.g. "url('blabla')" => "blabla" / "none" => ""). + * + * @private + * @param {string} value + * @returns {string} + */ + _getSrcFromCssValue: function (value) { + if (value === undefined) { + value = this.$target.css('background-image'); + } + return value.replace(/url\(['"]*|['"]*\)|^none$/g, ''); + }, + /** + * @override + */ + _setActive: function () { + this._super.apply(this, arguments); + + var src = this._getSrcFromCssValue(); + this.$el.find('[data-background]') + .removeClass('active') + .filter(function () { + var bgOption = $(this).data('background'); + return (bgOption === '' && src === '' || bgOption !== '' && src.indexOf(bgOption) >= 0); + }) + .addClass('active'); + + this.$el.find('[data-choose-image]').toggleClass('active', this.$target.hasClass('oe_custom_bg')); + }, + /** + * Sets the given value as custom background image. + * + * @private + * @param {string} value + */ + _setCustomBackground: function (value) { + this.__customImageSrc = value; + this.background(false, this.__customImageSrc); + this.$target.addClass('oe_custom_bg'); + this._setActive(); + this.$target.trigger('snippet-option-change', [this]); + }, +}); + +/** + * Handles the edition of snippet's background image position. + */ +registry.background_position = SnippetOption.extend({ + xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], + + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + var self = this; + this.$target.on('snippet-option-change', function () { + self.onFocus(); + }); + }, + /** + * @override + */ + onFocus: function () { + this.$el.toggleClass('d-none', this.$target.css('background-image') === 'none'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Opens a Dialog to edit the snippet's backgroung image position. + * + * @see this.selectClass for parameters + */ + backgroundPosition: function (previewMode, value, $opt) { + var self = this; + + this.previous_state = [this.$target.attr('class'), this.$target.css('background-size'), this.$target.css('background-position')]; + + this.bg_pos = self.$target.css('background-position').split(' '); + this.bg_siz = self.$target.css('background-size').split(' '); + + this.modal = new Dialog(null, { + title: _t("Background Image Sizing"), + $content: $(qweb.render('web_editor.dialog.background_position')), + buttons: [ + {text: _t("Ok"), classes: 'btn-primary', close: true, click: _.bind(this._saveChanges, this)}, + {text: _t("Discard"), close: true, click: _.bind(this._discardChanges, this)}, + ], + }).open(); + + this.modal.opened().then(function () { + // Fetch data form $target + var value = ((self.$target.hasClass('o_bg_img_opt_contain'))? 'contain' : ((self.$target.hasClass('o_bg_img_opt_custom'))? 'custom' : 'cover')); + self.modal.$('> label > input[value=' + value + ']').prop('checked', true); + + if (self.$target.hasClass('o_bg_img_opt_repeat')) { + self.modal.$('#o_bg_img_opt_contain_repeat').prop('checked', true); + self.modal.$('#o_bg_img_opt_custom_repeat').val('o_bg_img_opt_repeat'); + } else if (self.$target.hasClass('o_bg_img_opt_repeat_x')) { + self.modal.$('#o_bg_img_opt_custom_repeat').val('o_bg_img_opt_repeat_x'); + } else if (self.$target.hasClass('o_bg_img_opt_repeat_y')) { + self.modal.$('#o_bg_img_opt_custom_repeat').val('o_bg_img_opt_repeat_y'); + } + + if (self.bg_pos.length > 1) { + self.bg_pos = { + x: self.bg_pos[0], + y: self.bg_pos[1], + }; + self.modal.$('#o_bg_img_opt_custom_pos_x').val(self.bg_pos.x.replace('%', '')); + self.modal.$('#o_bg_img_opt_custom_pos_y').val(self.bg_pos.y.replace('%', '')); + } + if (self.bg_siz.length > 1) { + self.modal.$('#o_bg_img_opt_custom_size_x').val(self.bg_siz[0].replace('%', '')); + self.modal.$('#o_bg_img_opt_custom_size_y').val(self.bg_siz[1].replace('%', '')); + } + + // Focus Point + self.$focus = self.modal.$('.o_focus_point'); + self._updatePosInformation(); + + var img_url = /\(['"]?([^'"]+)['"]?\)/g.exec(self.$target.css('background-image')); + img_url = (img_url && img_url[1]) || ''; + var $img = $('<img/>', {class: 'img img-fluid', src: img_url}); + $img.on('load', function () { + self._bindImageEvents($img); + }); + $img.prependTo(self.modal.$('.o_bg_img_opt_object')); + + // Bind events + self.modal.$el.on('change', '> label > input', function (e) { + self.modal.$('> .o_bg_img_opt').addClass('o_hidden') + .filter('[data-value=' + e.target.value + ']') + .removeClass('o_hidden'); + }); + self.modal.$el.on('change', 'input, select', function (e) { + self._saveChanges(); + }); + self.modal.$('> label > input:checked').trigger('change'); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Bind events on the given image so that the users can adapt the focus + * point. + * + * @private + * @param {jQuery} $img + */ + _bindImageEvents: function ($img) { + var self = this; + + var mousedown = false; + $img.on('mousedown', function (e) { + mousedown = true; + }); + $img.on('mousemove', function (e) { + if (mousedown) { + _update(e); + } + }); + $img.on('mouseup', function (e) { + self.$focus.addClass('o_with_transition'); + _update(e); + setTimeout(function () { + self.$focus.removeClass('o_with_transition'); + }, 200); + mousedown = false; + }); + + function _update(e) { + var posX = e.pageX - $(e.target).offset().left; + var posY = e.pageY - $(e.target).offset().top; + self.bg_pos = { + x: clipValue(posX/$img.width()*100).toFixed(2) + '%', + y: clipValue(posY/$img.height()*100).toFixed(2) + '%', + }; + self._updatePosInformation(); + self._saveChanges(); + + function clipValue(value) { + return Math.max(0, Math.min(value, 100)); + } + } + }, + /** + * Removes all option-related classes and style on the target element. + * + * @private + */ + _clean: function () { + this.$target.removeClass('o_bg_img_opt_contain o_bg_img_opt_custom o_bg_img_opt_repeat o_bg_img_opt_repeat_x o_bg_img_opt_repeat_y') + .css({ + 'background-size': '', + 'background-position': '', + }); + }, + /** + * Restores the target style before last edition made with the option. + * + * @private + */ + _discardChanges: function () { + this._clean(); + if (this.previous_state) { + this.$target.addClass(this.previous_state[0]).css({ + 'background-size': this.previous_state[1], + 'background-position': this.previous_state[2], + }); + } + }, + /** + * Updates the visual representation of the chosen background position. + * + * @private + */ + _updatePosInformation: function () { + this.modal.$('.o_bg_img_opt_ui_info .o_x').text(this.bg_pos.x); + this.modal.$('.o_bg_img_opt_ui_info .o_y').text(this.bg_pos.y); + this.$focus.css({ + left: this.bg_pos.x, + top: this.bg_pos.y, + }); + }, + /** + * Updates the target element to match the chosen options. + * + * @private + */ + _saveChanges: function () { + this._clean(); + + var bg_img_size = this.modal.$('> :not(label):not(.o_hidden)').data('value') || 'cover'; + switch (bg_img_size) { + case 'cover': + this.$target.css('background-position', this.bg_pos.x + ' ' + this.bg_pos.y); + break; + case 'contain': + this.$target.addClass('o_bg_img_opt_contain'); + this.$target.toggleClass('o_bg_img_opt_repeat', this.modal.$('#o_bg_img_opt_contain_repeat').prop('checked')); + break; + case 'custom': + this.$target.addClass('o_bg_img_opt_custom'); + var sizeX = this.modal.$('#o_bg_img_opt_custom_size_x').val(); + var sizeY = this.modal.$('#o_bg_img_opt_custom_size_y').val(); + var posX = this.modal.$('#o_bg_img_opt_custom_pos_x').val(); + var posY = this.modal.$('#o_bg_img_opt_custom_pos_y').val(); + this.$target.addClass(this.modal.$('#o_bg_img_opt_custom_repeat').val()) + .css({ + 'background-size': ((sizeX)? sizeX + '%' : 'auto') + ' ' + ((sizeY)? sizeY + '%' : 'auto'), + 'background-position': ((posX)? posX + '%' : 'auto') + ' ' + ((posY)? posY + '%' : 'auto'), + }); + break; + } + }, +}); + +/** + * Allows to replace a text value with the name of a database record. + * @todo replace this mechanism with real backend m2o field ? + */ +registry.many2one = SnippetOption.extend({ + xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], + /** + * @override + */ + start: function () { + var self = this; + + this.Model = this.$target.data('oe-many2one-model'); + this.ID = +this.$target.data('oe-many2one-id'); + + // create search button and bind search bar + this.$btn = $(qweb.render('web_editor.many2one.button')) + .insertAfter(this.$overlay.find('.oe_options')); + + this.$ul = this.$btn.find('ul'); + this.$search = this.$ul.find('li:first'); + this.$search.find('input').on('mousedown click mouseup keyup keydown', function (e) { + e.stopPropagation(); + }); + + // move menu item + setTimeout(function () { + if (self.$overlay.find('.oe_options').hasClass('d-none')) { + self.$btn.css('height', '0').find('> a').addClass('d-none'); + self.$ul.show().css({ + 'top': '-24px', 'margin': '0', 'padding': '2px 0', 'position': 'relative' + }); + } else { + self.$btn.find('a').on('click', function (e) { + self._clear(); + }); + } + },0); + + // bind search input + this.$search.find('input') + .focus() + .on('keyup', function (e) { + self._findExisting($(this).val()); + }); + + // bind result + this.$ul.on('click', 'li:not(:first) a', function (e) { + self._selectRecord($(e.currentTarget)); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onFocus: function () { + this.$target.attr('contentEditable', 'false'); + this._clear(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Removes the input value and suggestions. + * + * @private + */ + _clear: function () { + var self = this; + this.$search.siblings().remove(); + self.$search.find('input').val(''); + setTimeout(function () { + self.$search.find('input').focus(); + }, 0); + }, + /** + * Find existing record with the given name and suggest them. + * + * @private + * @param {string} name + * @returns {Deferred} + */ + _findExisting: function (name) { + var self = this; + var domain = []; + if (!name || !name.length) { + self.$search.siblings().remove(); + return; + } + if (isNaN(+name)) { + if (this.Model !== 'res.partner') { + domain.push(['name', 'ilike', name]); + } else { + domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]); + } + } else { + domain.push(['id', '=', name]); + } + + return this._rpc({ + model: this.Model, + method: 'search_read', + args: [domain, this.Model === 'res.partner' ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']], + kwargs: { + order: [{name: 'name', asc: false}], + limit: 5, + }, + }).then(function (result) { + self.$search.siblings().remove(); + self.$search.after(qweb.render('web_editor.many2one.search',{contacts:result})); + }); + }, + /** + * Selects the given suggestion and displays it the proper way. + * + * @private + * @param {jQuery} $li + */ + _selectRecord: function ($li) { + var self = this; + + this.ID = +$li.data('id'); + this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID); + + this.trigger_up('request_history_undo_record', {$target: this.$target}); + this.$target.trigger('content_changed'); + + if (self.$target.data('oe-type') === 'contact') { + $('[data-oe-contact-options]') + .filter('[data-oe-model="'+self.$target.data('oe-model')+'"]') + .filter('[data-oe-id="'+self.$target.data('oe-id')+'"]') + .filter('[data-oe-field="'+self.$target.data('oe-field')+'"]') + .filter('[data-oe-contact-options!="'+self.$target.data('oe-contact-options')+'"]') + .add(self.$target) + .attr('data-oe-many2one-id', self.ID).data('oe-many2one-id', self.ID) + .each(function () { + var $node = $(this); + var options = $node.data('oe-contact-options'); + self._rpc({ + model: 'ir.qweb.field.contact', + method: 'get_record_to_html', + args: [[self.ID]], + kwargs: { + options: options, + }, + }).then(function (html) { + $node.html(html); + }); + }); + } else { + self.$target.html($li.data('name')); + } + + _.defer(function () { + self.trigger_up('deactivate_snippet'); + }); + } +}); + +return { + Class: SnippetOption, + registry: registry, +}; +}); diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js new file mode 100644 index 00000000000..e0968ecdb58 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -0,0 +1,2452 @@ +odoo.define('web_editor.summernote', function (require) { +'use strict'; + +var core = require('web.core'); +require('summernote/summernote'); // wait that summernote is loaded + +var _t = core._t; + +// Summernote Lib (neek hack to make accessible: method and object) +// var agent = $.summernote.core.agent; +var dom = $.summernote.core.dom; +var range = $.summernote.core.range; +var list = $.summernote.core.list; +var key = $.summernote.core.key; +var eventHandler = $.summernote.eventHandler; +var editor = eventHandler.modules.editor; +var renderer = $.summernote.renderer; +var options = $.summernote.options; + +// Browser-unify execCommand +var oldJustify = {}; +_.each(['Left', 'Right', 'Full', 'Center'], function (align) { + oldJustify[align] = editor['justify' + align]; + editor['justify' + align] = function ($editable, value) { + // Before calling the standard function, check all elements which have + // an 'align' attribute and mark them with their value + var $align = $editable.find('[align]'); + _.each($align, function (el) { + var $el = $(el); + $el.data('__align', $el.attr('align')); + }); + + // Call the standard function + oldJustify[align].apply(this, arguments); + + // Then: + + // Remove the text-align of elements which lost the 'align' attribute + var $newAlign = $editable.find('[align]'); + $align.not($newAlign).css('text-align', ''); + + // Transform the 'align' attribute into the 'text-align' css + // property for elements which received the 'align' attribute or whose + // 'align' attribute changed + _.each($newAlign, function (el) { + var $el = $(el); + + var oldAlignValue = $align.data('__align'); + var alignValue = $el.attr('align'); + if (oldAlignValue === alignValue) { + // If the element already had an 'align' attribute and that it + // did not changed, do nothing (compatibility) + return; + } + + $el.removeAttr('align'); + $el.css('text-align', alignValue); + + // Note the first step (removing the text-align of elemnts which + // lost the 'align' attribute) is kinda the same as this one, but + // this one handles the elements which have been edited with chrome + // or with this new system + $el.find('*').css('text-align', ''); + }); + + // Unmark the elements + $align.removeData('__align'); + }; +}); + + +// Add methods to summernote + +dom.hasContentAfter = function (node) { + var next; + if (dom.isEditable(node)) return; + while (node.nextSibling) { + next = node.nextSibling; + if (next.tagName || dom.isVisibleText(next) || dom.isBR(next)) return next; + node = next; + } +}; +dom.hasContentBefore = function (node) { + var prev; + if (dom.isEditable(node)) return; + while (node.previousSibling) { + prev = node.previousSibling; + if (prev.tagName || dom.isVisibleText(prev) || dom.isBR(prev)) return prev; + node = prev; + } +}; +dom.ancestorHaveNextSibling = function (node, pred) { + pred = pred || dom.hasContentAfter; + while (!dom.isEditable(node) && (!node.nextSibling || !pred(node))) { node = node.parentNode; } + return node; +}; +dom.ancestorHavePreviousSibling = function (node, pred) { + pred = pred || dom.hasContentBefore; + while (!dom.isEditable(node) && (!node.previousSibling || !pred(node))) { node = node.parentNode; } + return node; +}; +dom.nextElementSibling = function (node) { + while (node) { + node = node.nextSibling; + if (node && node.tagName) { + break; + } + } + return node; +}; +dom.previousElementSibling = function (node) { + while (node) { + node = node.previousSibling; + if (node && node.tagName) { + break; + } + } + return node; +}; +dom.lastChild = function (node) { + while (node.lastChild) { node = node.lastChild; } + return node; +}; +dom.firstChild = function (node) { + while (node.firstChild) { node = node.firstChild; } + return node; +}; +dom.lastElementChild = function (node, deep) { + node = deep ? dom.lastChild(node) : node.lastChild; + return !node || node.tagName ? node : dom.previousElementSibling(node); +}; +dom.firstElementChild = function (node, deep) { + node = deep ? dom.firstChild(node) : node.firstChild; + return !node || node.tagName ? node : dom.nextElementSibling(node); +}; +dom.isEqual = function (prev, cur) { + if (prev.tagName !== cur.tagName) { + return false; + } + if ((prev.attributes ? prev.attributes.length : 0) !== (cur.attributes ? cur.attributes.length : 0)) { + return false; + } + + function strip(text) { + return text && text.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' '); + } + var att, att2; + loop_prev: + for (var a in prev.attributes) { + att = prev.attributes[a]; + for (var b in cur.attributes) { + att2 = cur.attributes[b]; + if (att.name === att2.name) { + if (strip(att.value) !== strip(att2.value)) return false; + continue loop_prev; + } + } + return false; + } + return true; +}; +dom.hasOnlyStyle = function (node) { + for (var i = 0; i < node.attributes.length; i++) { + var attr = node.attributes[i]; + if (attr.attributeName !== 'style') { + return false; + } + } + return true; +}; +dom.hasProgrammaticStyle = function (node) { + var styles = ["float", "display", "position", "top", "left", "right", "bottom"]; + for (var i = 0; i < node.style.length; i++) { + var style = node.style[i]; + if (styles.indexOf(style) !== -1) { + return true; + } + } + return false; +}; +dom.mergeFilter = function (prev, cur, parent) { + // merge text nodes + if (prev && (dom.isText(prev) || (['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P'].indexOf(prev.tagName) !== -1 && prev !== cur.parentNode)) && dom.isText(cur)) { + return true; + } + if (prev && prev.tagName === "P" && dom.isText(cur)) { + return true; + } + if (prev && dom.isText(cur) && !dom.isVisibleText(cur) && (dom.isText(prev) || dom.isVisibleText(prev))) { + return true; + } + if (prev && !dom.isBR(prev) && dom.isEqual(prev, cur) && + ((prev.tagName && dom.getComputedStyle(prev).display === "inline" && + cur.tagName && dom.getComputedStyle(cur).display === "inline"))) { + return true; + } + if (dom.isEqual(parent, cur) && + ((parent.tagName && dom.getComputedStyle(parent).display === "inline" && + cur.tagName && dom.getComputedStyle(cur).display === "inline"))) { + return true; + } + if (parent && cur.tagName === "FONT" && (!cur.firstChild || (!cur.attributes.getNamedItem('style') && !cur.className.length))) { + return true; + } + // On backspace, webkit browsers create a <span> with a bunch of + // inline styles "remembering" where they come from. + // chances are we had e.g. + // <p>foo</p> + // <p>bar</p> + // merged the lines getting this in webkit + // <p>foo<span>bar</span></p> + if (parent && cur.tagName === "SPAN" && dom.hasOnlyStyle(cur) && !dom.hasProgrammaticStyle(cur)) { + return true; + } +}; +dom.doMerge = function (prev, cur) { + if (prev.tagName) { + if (prev.childNodes.length && !prev.textContent.match(/\S/) && dom.firstElementChild(prev) && dom.isBR(dom.firstElementChild(prev))) { + prev.removeChild(dom.firstElementChild(prev)); + } + if (cur.tagName) { + while (cur.firstChild) { + prev.appendChild(cur.firstChild); + } + cur.parentNode.removeChild(cur); + } else { + prev.appendChild(cur); + } + } else { + if (cur.tagName) { + var deep = cur; + while (deep.tagName && deep.firstChild) {deep = deep.firstChild;} + prev.appendData(deep.textContent); + cur.parentNode.removeChild(cur); + } else { + prev.appendData(cur.textContent); + cur.parentNode.removeChild(cur); + } + } +}; +dom.merge = function (node, begin, so, end, eo, mergeFilter, all) { + mergeFilter = mergeFilter || dom.mergeFilter; + var _merged = false; + var add = all || false; + + if (!begin) { + begin = node; + while (begin.firstChild) {begin = begin.firstChild;} + so = 0; + } else if (begin.tagName && begin.childNodes[so]) { + begin = begin.childNodes[so]; + so = 0; + } + if (!end) { + end = node; + while (end.lastChild) {end = end.lastChild;} + eo = end.textContent.length-1; + } else if (end.tagName && end.childNodes[so]) { + end = end.childNodes[so]; + so = 0; + } + + begin = dom.firstChild(begin); + if (dom.isText(begin) && so > begin.textContent.length) { + so = 0; + } + end = dom.firstChild(end); + if (dom.isText(end) && eo > end.textContent.length) { + eo = 0; + } + + function __merge(node) { + var merged = false; + var prev; + for (var k=0; k<node.childNodes.length; k++) { + var cur = node.childNodes[k]; + + if (cur === begin) { + if (!all) add = true; + } + + __merge(cur); + dom.orderClass(dom.node(cur)); + + if (!add || !cur) continue; + if (cur === end) { + if (!all) add = false; + } + + // create the first prev value + if (!prev) { + if (mergeFilter.call(dom, prev, cur, node)) { + prev = prev || cur.previousSibling; + dom.moveTo(cur, cur.parentNode, cur); + k--; + } else { + prev = cur; + } + continue; + } else if (mergeFilter.call(dom, null, cur, node)) { // merge with parent + prev = prev || cur.previousSibling; + dom.moveTo(cur, cur.parentNode, cur); + k--; + continue; + } + + // merge nodes + if (mergeFilter.call(dom, prev, cur, node)) { + var p = prev; + var c = cur; + // compute prev/end and offset + if (prev.tagName) { + if (cur.tagName) { + if (cur === begin) begin = prev; + if (cur === end) end = prev; + } + } else { + if (cur.tagName) { + var deep = cur; + while (deep.tagName && deep.lastChild) {deep = deep.lastChild;} + if (deep === begin) { + so += prev.textContent.length; + begin = prev; + } + if (deep === end) { + eo += prev.textContent.length; + end = prev; + } + } else { + // merge text nodes + if (cur === begin) { + so += prev.textContent.length; + begin = prev; + } + if (cur === end) { + eo += prev.textContent.length; + end = prev; + } + } + } + + dom.doMerge(p, c); + + merged = true; + k--; + continue; + } + + prev = cur; + } + + // an other loop to merge the new shibbing nodes + if (merged) { + _merged = true; + __merge(node); + } + } + if (node) { + __merge(node); + } + + return { + merged: _merged, + sc: begin, + ec: end, + so: so, + eo: eo + }; +}; +dom.autoMerge = function (target, previous) { + var node = dom.lastChild(target); + var nodes = []; + var temp; + + while (node) { + nodes.push(node); + temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node)); + if (temp) { + if (!dom.isText(node) && !dom.isMergable(node) && temp.tagName !== node.tagName) { + nodes = []; + } + break; + } + node = node.parentNode; + } + + while (nodes.length) { + node = nodes.pop(); + if (node && (temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node))) && + temp.tagName === node.tagName && + !dom.isText(node) && + dom.isMergable(node) && + !dom.isNotBreakable(node) && !dom.isNotBreakable(previous ? dom.previousElementSibling(node) : dom.nextElementSibling(node))) { + + if (previous) { + dom.doMerge(temp, node); + } else { + dom.doMerge(node, temp); + } + } + } +}; +dom.removeSpace = function (node, begin, so, end, eo) { + var removed = false; + var add = node === begin; + + if (node === begin && begin === end && dom.isBR(node)) { + return { + removed: removed, + sc: begin, + ec: end, + so: so, + eo: eo + }; + } + + (function __remove_space(node) { + if (!node) return; + var t_begin, t_end; + for (var k=0; k<node.childNodes.length; k++) { + var cur = node.childNodes[k]; + + if (cur === begin) add = true; + + if (cur.tagName && cur.tagName !== "SCRIPT" && cur.tagName !== "STYLE" && dom.getComputedStyle(cur).whiteSpace !== "pre") { + __remove_space(cur); + } + + if (!add) continue; + if (cur === end) add = false; + + // remove begin empty text node + if (node.childNodes.length > 1 && dom.isText(cur) && !dom.isVisibleText(cur)) { + removed = true; + if (cur === begin) { + t_begin = dom.hasContentBefore(dom.ancestorHavePreviousSibling(cur)); + if (t_begin) { + so = 0; + begin = dom.lastChild(t_begin); + } + } + if (cur === end) { + t_end = dom.hasContentAfter(dom.ancestorHaveNextSibling(cur)); + if (t_end) { + eo = 1; + end = dom.firstChild(t_end); + if (dom.isText(end)) { + eo = end.textContent.length; + } + } + } + cur.parentNode.removeChild(cur); + begin = dom.lastChild(begin); + end = dom.lastChild(end); + k--; + continue; + } + + // convert HTML space + if (dom.isText(cur)) { + var text; + var temp; + var _temp; + var exp1 = /[\t\n\r ]+/g; + var exp2 = /(?!([ ]|\u00A0)|^)\u00A0(?!([ ]|\u00A0)|$)/g; + if (cur === begin) { + temp = cur.textContent.substr(0, so); + _temp = temp.replace(exp1, ' ').replace(exp2, ' '); + so -= temp.length - _temp.length; + } + if (cur === end) { + temp = cur.textContent.substr(0, eo); + _temp = temp.replace(exp1, ' ').replace(exp2, ' '); + eo -= temp.length - _temp.length; + } + text = cur.textContent.replace(exp1, ' ').replace(exp2, ' '); + removed = removed || cur.textContent.length !== text.length; + cur.textContent = text; + } + } + })(node); + + return { + removed: removed, + sc: begin, + ec: end, + so: !dom.isBR(begin) && so > 0 ? so : 0, + eo: dom.isBR(end) ? 0 : eo + }; +}; +dom.removeBetween = function (sc, so, ec, eo, towrite) { + var text; + if (ec.tagName) { + if (ec.childNodes[eo]) { + ec = ec.childNodes[eo]; + eo = 0; + } else { + ec = dom.lastChild(ec); + eo = dom.nodeLength(ec); + } + } + if (sc.tagName) { + sc = sc.childNodes[so] || dom.firstChild(ec); + so = 0; + if (!dom.hasContentBefore(sc) && towrite) { + sc.parentNode.insertBefore(document.createTextNode('\u00A0'), sc); + } + } + if (!eo && sc !== ec) { + ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(ec)) || ec); + eo = ec.textContent.length; + } + + var ancestor = dom.commonAncestor(sc.tagName ? sc.parentNode : sc, ec.tagName ? ec.parentNode : ec) || dom.ancestor(sc, dom.isEditable); + + if (!dom.isContentEditable(ancestor)) { + return { + sc: sc, + so: so, + ec: sc, + eo: eo + }; + } + + if (ancestor.tagName) { + var ancestor_sc = sc; + var ancestor_ec = ec; + while (ancestor !== ancestor_sc && ancestor !== ancestor_sc.parentNode) { ancestor_sc = ancestor_sc.parentNode; } + while (ancestor !== ancestor_ec && ancestor !== ancestor_ec.parentNode) { ancestor_ec = ancestor_ec.parentNode; } + + + var node = dom.node(sc); + if (!dom.isNotBreakable(node) && !dom.isVoid(sc)) { + sc = dom.splitTree(ancestor_sc, {'node': sc, 'offset': so}); + } + var before = dom.hasContentBefore(dom.ancestorHavePreviousSibling(sc)); + + var after; + if (ec.textContent.slice(eo, Infinity).match(/\S|\u00A0/)) { + after = dom.splitTree(ancestor_ec, {'node': ec, 'offset': eo}); + } else { + after = dom.hasContentAfter(dom.ancestorHaveNextSibling(ec)); + } + + var nodes = dom.listBetween(sc, ec); + + var ancestor_first_last = function (node) { + return node === before || node === after; + }; + + for (var i=0; i<nodes.length; i++) { + if (!dom.ancestor(nodes[i], ancestor_first_last) && !$.contains(nodes[i], before) && !$.contains(nodes[i], after) && !dom.isEditable(nodes[i])) { + nodes[i].parentNode.removeChild(nodes[i]); + } + } + + if (dom.listAncestor(after).length <= dom.listAncestor(before).length) { + sc = dom.lastChild(before || ancestor); + so = dom.nodeLength(sc); + } else { + sc = dom.firstChild(after); + so = 0; + } + + if (dom.isVoid(node)) { + // we don't need to append a br + } else if (towrite && !node.firstChild && node.parentNode && !dom.isNotBreakable(node)) { + var br = $("<br/>")[0]; + node.appendChild(sc); + sc = br; + so = 0; + } else if (!ancestor.children.length && !ancestor.textContent.match(/\S|\u00A0/)) { + sc = $("<br/>")[0]; + so = 0; + $(ancestor).prepend(sc); + } else if (dom.isText(sc)) { + text = sc.textContent.replace(/[ \t\n\r]+$/, '\u00A0'); + so = Math.min(so, text.length); + sc.textContent = text; + } + } else { + text = ancestor.textContent; + ancestor.textContent = text.slice(0, so) + text.slice(eo, Infinity).replace(/^[ \t\n\r]+/, '\u00A0'); + } + + eo = so; + if (!dom.isBR(sc) && !dom.isVisibleText(sc) && !dom.isText(dom.hasContentBefore(sc)) && !dom.isText(dom.hasContentAfter(sc))) { + ancestor = dom.node(sc); + text = document.createTextNode('\u00A0'); + $(sc).before(text); + sc = text; + so = 0; + eo = 1; + } + + var parentNode = sc && sc.parentNode; + if (parentNode && sc.tagName === 'BR') { + sc = parentNode; + ec = parentNode; + } + + return { + sc: sc, + so: so, + ec: sc, + eo: eo + }; +}; +dom.indent = function (node) { + var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; + var margin = parseFloat(node.style[style] || 0)+1.5; + node.style[style] = margin + "em"; + return margin; +}; +dom.outdent = function (node) { + var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft'; + var margin = parseFloat(node.style[style] || 0)-1.5; + node.style[style] = margin > 0 ? margin + "em" : ""; + return margin; +}; +dom.scrollIntoViewIfNeeded = function (node) { + node = dom.node(node); + + var $span; + if (dom.isBR(node)) { + $span = $('<span/>').text('\u00A0'); + $(node).after($span); + node = $span[0]; + } + + if (node.scrollIntoViewIfNeeded) { + node.scrollIntoViewIfNeeded(false); + } else { + var offsetParent = node.offsetParent; + while (offsetParent) { + var elY = 0; + var elH = node.offsetHeight; + var parent = node; + + while (offsetParent && parent) { + elY += node.offsetTop; + + // get if a parent have a scrollbar + parent = node.parentNode; + while (parent !== offsetParent && + (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(parent).overflowY) === -1)) { + parent = parent.parentNode; + } + node = parent; + + if (parent !== offsetParent) { + elY -= parent.offsetTop; + parent = null; + } + + offsetParent = node.offsetParent; + } + + if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(node).overflowY) !== -1) && + (node.scrollTop + node.clientHeight) < (elY + elH)) { + node.scrollTop = (elY + elH) - node.clientHeight; + } + } + } + + if ($span) { + $span.remove(); + } + + return; +}; +dom.moveTo = function (node, target, before) { + var nodes = []; + while (node.firstChild) { + nodes.push(node.firstChild); + if (before) { + target.insertBefore(node.firstChild, before); + } else { + target.appendChild(node.firstChild); + } + } + node.parentNode.removeChild(node); + return nodes; +}; +dom.isMergable = function (node) { + return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font".indexOf(node.tagName.toLowerCase()) !== -1; +}; +dom.isSplitable = function (node) { + return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a font".indexOf(node.tagName.toLowerCase()) !== -1; +}; +dom.isRemovableEmptyNode = function (node) { + return "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font span br".indexOf(node.tagName.toLowerCase()) !== -1; +}; +dom.isForbiddenNode = function (node) { + return node.tagName === "BR" || $(node).is(".fa, img"); +}; +/** + * @todo 'so' and 'eo' were added as a bugfix and are not given everytime. They + * however should be as the function may be wrong without them (for example, + * when asking the list between an element and its parent, as there is no path + * from the beginning of the former to the beginning of the later). + */ +dom.listBetween = function (sc, ec, so, eo) { + var nodes = []; + var ancestor = dom.commonAncestor(sc, ec); + dom.walkPoint({'node': sc, 'offset': so || 0}, {'node': ec, 'offset': eo || 0}, function (point) { + if (ancestor !== point.node || ancestor === sc || ancestor === ec) { + nodes.push(point.node); + } + }); + return list.unique(nodes); +}; +dom.isNotBreakable = function (node) { + // avoid triple click => crappy dom + return !dom.isText(node) && !dom.isBR(dom.firstChild(node)) && dom.isVoid(dom.firstChild(node)); +}; +dom.isContentEditable = function (node) { + return $(node).closest('[contenteditable]').prop('contenteditable') === 'true'; +}; +dom.isContentEditableFalse = function (node) { + return $(node).closest('[contenteditable]').prop('contenteditable') === 'false'; +}; +dom.isFont = function (node) { + var nodeName = node && node.nodeName.toUpperCase(); + return node && (nodeName === "FONT" || + (nodeName === "SPAN" && ( + node.className.match(/(^|\s)fa(\s|$)/i) || + node.className.match(/(^|\s)(text|bg)-/i) || + (node.attributes.style && node.attributes.style.value.match(/(^|\s)(color|background-color|font-size):/i)))) ); +}; +dom.isVisibleText = function (textNode) { + return !!textNode.textContent.match(/\S|\u00A0/); +}; +var old_isVisiblePoint = dom.isVisiblePoint; +dom.isVisiblePoint = function (point) { + return point.node.nodeType !== 8 && old_isVisiblePoint.apply(this, arguments); +}; +dom.orderStyle = function (node) { + var style = node.getAttribute('style'); + if (!style) return null; + style = style.replace(/[\s\n\r]+/, ' ').replace(/^ ?;? ?| ?;? ?$/g, '').replace(/ ?; ?/g, ';'); + if (!style.length) { + node.removeAttribute("style"); + return null; + } + style = style.split(";"); + style.sort(); + style = style.join("; ")+";"; + node.setAttribute('style', style); + return style; +}; +dom.orderClass = function (node) { + var className = node.getAttribute && node.getAttribute('class'); + if (!className) return null; + className = className.replace(/[\s\n\r]+/, ' ').replace(/^ | $/g, '').replace(/ +/g, ' '); + if (!className.length) { + node.removeAttribute("class"); + return null; + } + className = className.split(" "); + className.sort(); + className = className.join(" "); + node.setAttribute('class', className); + return className; +}; +dom.node = function (node) { + return dom.isText(node) ? node.parentNode : node; +}; +dom.moveContent = function (from, to) { + if (from === to) { + return; + } + if (from.parentNode === to) { + while (from.lastChild) { + dom.insertAfter(from.lastChild, from); + } + } else { + while (from.firstChild && from.firstChild !== to) { + to.appendChild(from.firstChild); + } + } +}; +dom.getComputedStyle = function (node) { + return node.nodeType === Node.COMMENT_NODE ? {} : window.getComputedStyle(node); +}; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) { + var sc = this.sc; + var so = this.so; + var ec = this.ec; + var eo = this.eo; + isNotBreakable = isNotBreakable || dom.isNotBreakable; + + // search the first snippet editable node + var start = keep_end ? ec : sc; + while (start) { + if (isNotBreakable(start, sc, so, ec, eo)) { + break; + } + start = start.parentNode; + } + + // check if the end caret have the same node + var lastFilterEnd; + var end = keep_end ? sc : ec; + while (end) { + if (start === end) { + break; + } + if (isNotBreakable(end, sc, so, ec, eo)) { + lastFilterEnd = end; + } + end = end.parentNode; + } + if (lastFilterEnd) { + end = lastFilterEnd; + } + if (!end) { + end = document.getElementsByTagName('body')[0]; + } + + // if same node, keep range + if (start === end || !start) { + return this; + } + + // reduce or extend the range to don't break a isNotBreakable area + if ($.contains(start, end)) { + + if (keep_end) { + sc = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || sc); + so = sc.textContent.length; + } else if (!eo) { + ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || ec); + eo = ec.textContent.length; + } else { + ec = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(end)) || ec); + eo = 0; + } + } else { + + if (keep_end) { + sc = dom.firstChild(start); + so = 0; + } else { + ec = dom.lastChild(start); + eo = ec.textContent.length; + } + } + + return new range.WrappedRange(sc, so, ec, eo); +}; +/** + * Returns the image the range is in or matches (if any, false otherwise). + * + * @todo this implementation may not cover all corner cases but should do the + * trick for all reproductible ones + * @returns {DOMElement|boolean} + */ +range.WrappedRange.prototype.isOnImg = function () { + // If not a selection but a cursor position, just check if a point's + // ancestor is an image or not + if (this.sc === this.ec && this.so === this.eo) { + return dom.ancestor(this.sc, dom.isImg); + } + + var startPoint = {node: this.sc, offset: this.so}; + var endPoint = {node: this.ec, offset: this.eo}; + + var nb = 0; + var image; + var textNode; + dom.walkPoint(startPoint, endPoint, function (point) { + // If the element has children (not a text node and not empty node), + // the element cannot be considered as selected (these children will + // be processed to determine that) + if (dom.hasChildren(point.node)) { + return; + } + + // Check if an ancestor of the current point is an image + var pointImg = dom.ancestor(point.node, dom.isImg); + var isText = dom.isText(point.node); + + // Check if a visible element is selected, i.e. + // - If an ancestor of the current is an image we did not see yet + // - If the point is not in a br or a text (so a node with no children) + // - If the point is in a non empty text node we already saw + if (pointImg ? + (image !== pointImg) : + ((!dom.isBR(point.node) && !isText) || (textNode === point.node && point.node.textContent.match(/\S|\u00A0/)))) { + nb++; + } + + // If an ancestor of the current point is an image, then save it as the + // image we are looking for + if (pointImg) { + image = pointImg; + } + // If the current point is a text node save it as the last text node + // seen (if we see it again, this might mean it is selected) + if (isText) { + textNode = point.node; + } + }); + + return nb === 1 && image; +}; +range.WrappedRange.prototype.deleteContents = function (towrite) { + if (this.sc === this.ec && this.so === this.eo) { + return this; + } + + var r; + var image = this.isOnImg(); + if (image) { + // If the range matches/is in an image, then the image is to be removed + // and the cursor moved to its previous position + var parentNode = image.parentNode; + var index = _.indexOf(parentNode.childNodes, image); + parentNode.removeChild(image); + r = new range.WrappedRange(parentNode, index, parentNode, index); + } else { + r = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite); + } + + $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler + return new range.WrappedRange(r.sc, r.so, r.ec, r.eo); +}; +range.WrappedRange.prototype.clean = function (mergeFilter, all) { + var node = dom.node(this.sc === this.ec ? this.sc : this.commonAncestor()); + node = node || $(this.sc).closest('[contenteditable]')[0]; + if (node.childNodes.length <=1) { + return this; + } + + var merge = dom.merge(node, this.sc, this.so, this.ec, this.eo, mergeFilter, all); + var rem = dom.removeSpace(node.parentNode, merge.sc, merge.so, merge.ec, merge.eo); + + if (merge.merged || rem.removed) { + return range.create(rem.sc, rem.so, rem.ec, rem.eo); + } + return this; +}; +range.WrappedRange.prototype.remove = function (mergeFilter) { +}; +range.WrappedRange.prototype.isOnCellFirst = function () { + var node = dom.ancestor(this.sc, function (node) {return ["LI", "DIV", "TD","TH"].indexOf(node.tagName) !== -1;}); + return node && ["TD","TH"].indexOf(node.tagName) !== -1; +}; +range.WrappedRange.prototype.isContentEditable = function () { + return dom.isContentEditable(this.sc) && (this.sc === this.ec || dom.isContentEditable(this.ec)); +}; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +renderer.tplButtonInfo.fontsize = function (lang, options) { + var items = options.fontSizes.reduce(function (memo, v) { + return memo + '<a data-event="fontSize" href="#" class="dropdown-item" data-value="' + v + '">' + + '<i class="fa fa-check"></i> ' + v + + '</a>'; + }, ''); + + var sLabel = '<span class="note-current-fontsize">11</span>'; + return renderer.getTemplate().button(sLabel, { + title: lang.font.size, + dropdown: '<div class="dropdown-menu">' + items + '</div>' + }); +}; + +renderer.tplButtonInfo.color = function (lang, options) { + var foreColorButtonLabel = '<i class="' + options.iconPrefix + options.icons.color.recent + '"></i>'; + var backColorButtonLabel = '<i class="' + options.iconPrefix + 'paint-brush"></i>'; + // TODO Remove recent color button if possible. + // It is still put to avoid JS errors when clicking other buttons as the + // editor still expects it to exist. + var recentColorButton = renderer.getTemplate().button(foreColorButtonLabel, { + className: 'note-recent-color d-none', + title: lang.color.foreground, + event: 'color', + value: '{"backColor":"#B35E9B"}' + }); + var foreColorItems = [ + '<li><div class="btn-group flex-column">', + '<div class="note-color-palette" data-target-event="foreColor"></div>', + '<h6 class="note-custom-color mt8" data-event="customColor" data-value="foreColor" title="' + lang.color.custom + '">', + lang.color.custom + '</h6>', + '<div class="note-custom-color-palette" data-target-event="foreColor"></div>', + '</div></li>', + ]; + var backColorItems = [ + '<li><div class="btn-group flex-column">', + '<div class="note-color-reset" data-event="backColor" data-value="inherit" title="' + lang.color.transparent + '">', + lang.color.setTransparent + '</div>', + '<div class="note-color-palette" data-target-event="backColor"></div>', + '<h6 class="note-custom-color mt8" data-event="customColor" data-value="backColor" title="' + lang.color.custom + '">', + lang.color.custom + '</h6>', + '<div class="note-custom-color-palette" data-target-event="backColor"></div>', + '</div></li>', + ]; + var foreColorButton = renderer.getTemplate().button(foreColorButtonLabel, { + className: 'note-fore-color-preview mx-1', + title: lang.color.foreground, + dropdown: renderer.getTemplate().dropdown(foreColorItems) + }); + var backColorButton = renderer.getTemplate().button(backColorButtonLabel, { + className: 'note-back-color-preview mx-1', + title: lang.color.background, + dropdown: renderer.getTemplate().dropdown(backColorItems) + }); + return recentColorButton + foreColorButton + backColorButton; +}, + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +key.nameFromCode[46] = 'DELETE'; +key.nameFromCode[27] = 'ESCAPE'; + +options.keyMap.pc['BACKSPACE'] = 'backspace'; +options.keyMap.pc['DELETE'] = 'delete'; +options.keyMap.pc['ENTER'] = 'enter'; +options.keyMap.pc['ESCAPE'] = 'cancel'; +options.keyMap.mac['SHIFT+TAB'] = 'untab'; +options.keyMap.pc['UP'] = 'up'; +options.keyMap.pc['DOWN'] = 'down'; + +options.keyMap.mac['BACKSPACE'] = 'backspace'; +options.keyMap.mac['DELETE'] = 'delete'; +options.keyMap.mac['ENTER'] = 'enter'; +options.keyMap.mac['ESCAPE'] = 'cancel'; +options.keyMap.mac['UP'] = 'up'; +options.keyMap.mac['DOWN'] = 'down'; + +options.styleTags = ['p', 'pre', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote']; + +$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) { + var $editable = layoutInfo.editable(); + var dimension = sDim.split('x'); + var r = range.create(); + if (!r) return; + r = r.deleteContents(true); + + var table = editor.table.createTable(dimension[0], dimension[1]); + var parent = r.sc; + while (dom.isText(parent.parentNode) || dom.isRemovableEmptyNode(parent.parentNode)) { + parent = parent.parentNode; + } + var node = dom.splitTree(parent, {'node': r.sc, 'offset': r.so}) || r.sc; + node.parentNode.insertBefore(table, node); + + if ($(node).text() === '' || node.textContent === '\u00A0') { + node.parentNode.removeChild(node); + } + + editor.afterCommand($editable); + event.preventDefault(); + return false; +}; +$.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable, 'tab'); + var r = range.create(); + outdent = outdent || false; + event.preventDefault(); + + if (r && (dom.ancestor(r.sc, dom.isCell) || dom.ancestor(r.ec, dom.isCell))) { + if (r.isCollapsed() && r.isOnCell() && r.isOnCellFirst()) { + var td = dom.ancestor(r.sc, dom.isCell); + if (!outdent && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode)) { + var last = dom.lastChild(td); + range.create(last, dom.nodeLength(last), last, dom.nodeLength(last)).select(); + $.summernote.pluginEvents.enter(event, editor, layoutInfo); + } else if (outdent && !dom.previousElementSibling(td) && !$(td.parentNode).text().match(/\S/)) { + $.summernote.pluginEvents.backspace(event, editor, layoutInfo); + } else { + editor.table.tab(r, outdent); + } + } else { + $.summernote.pluginEvents.indent(event, editor, layoutInfo, outdent); + } + } else if (r && r.isCollapsed()) { + if (!r.sc.textContent.slice(0,r.so).match(/\S/) && r.isOnList()) { + if (outdent) { + $.summernote.pluginEvents.outdent(event, editor, layoutInfo); + } else { + $.summernote.pluginEvents.indent(event, editor, layoutInfo); + } + } else { + var next; + if (!outdent) { + if (dom.isText(r.sc)) { + next = r.sc.splitText(r.so); + } else { + next = document.createTextNode(''); + $(r.sc.childNodes[r.so]).before(next); + } + editor.typing.insertTab($editable, r, options.tabsize); + r = range.create(next, 0, next, 0); + r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true); + range.create(r.sc, r.so, r.ec, r.eo).select(); + } else { + r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true); + r = range.create(r.sc, r.so, r.ec, r.eo); + if (r.sc.splitText) { + next = r.sc.splitText(r.so); + r.sc.textContent = r.sc.textContent.replace(/(\u00A0)+$/g, ''); + next.textContent = next.textContent.replace(/^(\u00A0)+/g, ''); + range.create(r.sc, r.sc.textContent.length, r.sc, r.sc.textContent.length).select(); + } + } + } + } + return false; +}; +$.summernote.pluginEvents.untab = function (event, editor, layoutInfo) { + return $.summernote.pluginEvents.tab(event, editor, layoutInfo, true); +}; +$.summernote.pluginEvents.up = function (event, editor, layoutInfo) { + var r = range.create(); + var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc); + if (!r.isOnCell()) { + return; + } + // check if an ancestor between node and cell has content before + var ancestor = dom.ancestor(node, function (ancestorNode) { + return dom.hasContentBefore(ancestorNode) || dom.isCell(ancestorNode); + }); + if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentBefore(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentBefore(dom.hasContentBefore(ancestor)))) { + return; + } + event.preventDefault(); + var td = dom.ancestor(r.sc, dom.isCell); + var tr = td.parentNode; + var target = tr.previousElementSibling && tr.previousElementSibling.children[_.indexOf(tr.children, td)]; + if (!target) { + target = (dom.ancestorHavePreviousSibling(tr) || tr).previousSibling; + } + if (target) { + range.create(dom.lastChild(target), dom.lastChild(target).textContent.length).select(); + } +}; +$.summernote.pluginEvents.down = function (event, editor, layoutInfo) { + var r = range.create(); + var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc); + if (!r.isOnCell()) { + return; + } + // check if an ancestor between node and cell has content after + var ancestor = dom.ancestor(node, function (ancestorNode) { + return dom.hasContentAfter(ancestorNode) || dom.isCell(ancestorNode); + }); + if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentAfter(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentAfter(dom.hasContentAfter(ancestor)))) { + return; + } + event.preventDefault(); + var td = dom.ancestor(r.sc, dom.isCell); + var tr = td.parentNode; + var target = tr.nextElementSibling && tr.nextElementSibling.children[_.indexOf(tr.children, td)]; + if (!target) { + target = (dom.ancestorHaveNextSibling(tr) || tr).nextSibling; + } + if (target) { + range.create(dom.firstChild(target), 0).select(); + } +}; +$.summernote.pluginEvents.enter = function (event, editor, layoutInfo) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable, 'enter'); + + var r = range.create(); + if (!r.isContentEditable()) { + event.preventDefault(); + return false; + } + if (!r.isCollapsed()) { + r = r.deleteContents(); + r.select(); + } + + var br = $("<br/>")[0]; + + // set selection outside of A if range is at beginning or end + var elem = dom.isBR(elem) ? elem.parentNode : dom.node(r.sc); + if (elem.tagName === "A") { + if (r.so === 0 && dom.firstChild(elem) === r.sc) { + r.ec = r.sc = dom.hasContentBefore(elem) || $(dom.createText('')).insertBefore(elem)[0]; + r.eo = r.so = dom.nodeLength(r.sc); + r.select(); + } else if (dom.nodeLength(r.sc) === r.so && dom.lastChild(elem) === r.sc) { + r.ec = r.sc = dom.hasContentAfter(elem) || dom.insertAfter(dom.createText(''), elem); + r.eo = r.so = 0; + r.select(); + } + } + + var node; + var $node; + var $clone; + var contentBefore = r.sc.textContent.slice(0,r.so).match(/\S|\u00A0/); + if (!contentBefore && dom.isText(r.sc)) { + node = r.sc.previousSibling; + while (!contentBefore && node && dom.isText(node)) { + contentBefore = dom.isVisibleText(node); + node = node.previousSibling; + } + } + + node = dom.node(r.sc); + var exist = r.sc.childNodes[r.so] || r.sc; + exist = dom.isVisibleText(exist) || dom.isBR(exist) ? exist : dom.hasContentAfter(exist) || (dom.hasContentBefore(exist) || exist); + + // table: add a tr + var td = dom.ancestor(node, dom.isCell); + if (td && !dom.nextElementSibling(node) && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode) && (!dom.isText(r.sc) || !r.sc.textContent.slice(r.so).match(/\S|\u00A0/))) { + $node = $(td.parentNode); + $clone = $node.clone(); + $clone.children().html(dom.blank); + $node.after($clone); + node = dom.firstElementChild($clone[0]) || $clone[0]; + range.create(node, 0, node, 0).select(); + dom.scrollIntoViewIfNeeded(br); + event.preventDefault(); + return false; + } + + var last = node; + while (node && dom.isSplitable(node) && !dom.isList(node)) { + last = node; + node = node.parentNode; + } + + if (last === node && !dom.isBR(node)) { + node = r.insertNode(br, true); + if (isFormatNode(last.firstChild) && $(last).closest(options.styleTags.join(',')).length) { + dom.moveContent(last.firstChild, last); + last.removeChild(last.firstChild); + } + do { + node = dom.hasContentAfter(node); + } while (node && dom.isBR(node)); + + // create an other br because the user can't see the new line with only br in a block + if (!node && (!br.nextElementSibling || !dom.isBR(br.nextElementSibling))) { + $(br).before($("<br/>")[0]); + } + node = br.nextSibling || br; + } else if (last === node && dom.isBR(node)) { + $(node).after(br); + node = br; + } else if (!r.so && r.isOnList() && !r.sc.textContent.length && !dom.ancestor(r.sc, dom.isLi).nextElementSibling) { + // double enter on the end of a list = new line out of the list + $('<p></p>').append(br).insertAfter(dom.ancestor(r.sc, dom.isList)); + node = br; + } else if (dom.isBR(exist) && $(r.sc).closest('blockquote, pre').length && !dom.hasContentAfter($(exist.parentNode).closest('blockquote *, pre *').length ? exist.parentNode : exist)) { + // double enter on the end of a blockquote & pre = new line out of the list + $('<p></p>').append(br).insertAfter($(r.sc).closest('blockquote, pre')); + node = br; + } else if (dom.isEditable(dom.node(r.sc))) { + // if we are directly in an editable, only SHIFT + ENTER should add a newline + node = null; + } else if (last === r.sc) { + if (dom.isBR(last)) { + last = last.parentNode; + } + $node = $(last); + $clone = $node.clone().text(""); + $node.after($clone); + node = dom.node(dom.firstElementChild($clone[0]) || $clone[0]); + $(node).html(br); + node = br; + } else { + node = dom.splitTree(last, {'node': r.sc, 'offset': r.so}) || r.sc; + if (!contentBefore) { + // dom.node chooses the parent if node is text + var cur = dom.node(dom.lastChild(node.previousSibling)); + if (!dom.isBR(cur)) { + // We should concat what was before with a <br> + $(cur).html(cur.innerHTML + br.outerHTML); + } + } + if (!dom.isVisibleText(node)) { + node = dom.firstChild(node); + $(dom.node( dom.isBR(node) ? node.parentNode : node )).html(br); + node = br; + } + } + + if (node) { + node = dom.firstChild(node); + if (dom.isBR(node)) { + range.createFromNode(node).select(); + } else { + range.create(node,0).select(); + } + dom.scrollIntoViewIfNeeded(node); + } + event.preventDefault(); + return false; +}; +$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable, "visible"); + + var r = range.create(); + if (!r) return; + + if (!r.isCollapsed()) { + if ((dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) && dom.node(r.sc) !== dom.node(r.ec)) { + remove_table_content(r); + r = range.create(r.ec, 0); + } + r.select(); + } + + // don't write in forbidden tag (like span for font awsome) + var node = dom.firstChild(r.sc.tagName && r.so ? r.sc.childNodes[r.so] || r.sc : r.sc); + while (node.parentNode) { + if (dom.isForbiddenNode(node)) { + var text = node.previousSibling; + if (text && dom.isText(text) && dom.isVisibleText(text)) { + range.create(text, text.textContent.length, text, text.textContent.length).select(); + } else { + text = node.parentNode.insertBefore(document.createTextNode( "." ), node); + range.create(text, 1, text, 1).select(); + setTimeout(function () { + var text = range.create().sc; + text.textContent = text.textContent.replace(/^./, ''); + range.create(text, text.textContent.length, text, text.textContent.length).select(); + },0); + } + break; + } + node = node.parentNode; + } + + return true; +}; + +function remove_table_content(r) { + var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo); + if (dom.isText(r.sc)) { + r.sc.textContent = r.sc.textContent.slice(0, r.so); + } + if (dom.isText(r.ec)) { + r.ec.textContent = r.ec.textContent.slice(r.eo); + } + for (var i in nodes) { + var node = nodes[i]; + if (node === r.sc || node === r.ec || $.contains(node, r.sc) || $.contains(node, r.ec)) { + continue; + } else if (dom.isCell(node)) { + $(node).html("<br/>"); + } else if (node.parentNode) { + do { + var parent = node.parentNode; + parent.removeChild(node); + node = parent; + } while (!dom.isVisibleText(node) && !dom.firstElementChild(node) && + !dom.isCell(node) && + node.parentNode && !$(node.parentNode).hasClass('o_editable')); + } + } + return false; +} + +$.summernote.pluginEvents.delete = function (event, editor, layoutInfo) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable, "delete"); + + var r = range.create(); + if (!r) return; + if (!r.isContentEditable()) { + event.preventDefault(); + return false; + } + if (!r.isCollapsed()) { + if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) { + remove_table_content(r); + range.create(r.ec, 0).select(); + } else { + r = r.deleteContents(); + r.select(); + } + event.preventDefault(); + return false; + } + + var target = r.ec; + var offset = r.eo; + if (target.tagName && target.childNodes[offset]) { + target = target.childNodes[offset]; + offset = 0; + } + + var node = dom.node(target); + var data = dom.merge(node, target, offset, target, offset, null, true); + data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo); + r = range.create(data.sc, data.so); + r.select(); + target = r.sc; + offset = r.so; + + while (!dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;} + + var contentAfter = target.textContent.slice(offset,Infinity).match(/\S|\u00A0/); + var content = target.textContent.replace(/[ \t\r\n]+$/, ''); + var temp; + var temp2; + var next; + + // media + if (dom.isImg(node) || (!contentAfter && dom.isImg(dom.hasContentAfter(node)))) { + var parent; + var index; + if (!dom.isImg(node)) { + node = dom.hasContentAfter(node); + } + while (dom.isImg(node)) { + parent = node.parentNode; + index = dom.position(node); + if (index>0) { + next = node.previousSibling; + r = range.create(next, next.textContent.length); + } else { + r = range.create(parent, 0); + } + if (!dom.hasContentAfter(node) && !dom.hasContentBefore(node)) { + parent.appendChild($('<br/>')[0]); + } + parent.removeChild(node); + node = parent; + r.select(); + } + } + // empty tag + else if (!content.length && target.tagName && dom.isRemovableEmptyNode(dom.isBR(target) ? target.parentNode : target)) { + if (node === $editable[0] || $.contains(node, $editable[0])) { + event.preventDefault(); + return false; + } + var before = false; + next = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); + if (!dom.isContentEditable(next)) { + before = true; + next = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); + } + dom.removeSpace(next.parentNode, next, 0, next, 0); // clean before jump for not select invisible space between 2 tag + next = dom.firstChild(next); + node.parentNode.removeChild(node); + range.create(next, before ? next.textContent.length : 0).select(); + } + // normal feature if same tag and not the end + else if (contentAfter) { + return true; + } + // merge with the next text node + else if (dom.isText(target) && (temp = dom.hasContentAfter(target)) && dom.isText(temp)) { + return true; + } + //merge with the next block + else if ((temp = dom.ancestorHaveNextSibling(target)) && + !r.isOnCell() && + dom.isMergable(temp) && + dom.isMergable(temp2 = dom.hasContentAfter(temp)) && + temp.tagName === temp2.tagName && + (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li + !dom.isNotBreakable(temp) && + !dom.isNotBreakable(temp2)) { + dom.autoMerge(target, false); + next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target))); + if (dom.isBR(next)) { + if (dom.position(next) === 0) { + range.create(next.parentNode, 0).select(); + } + else { + range.create(next.previousSibling, next.previousSibling.textContent.length).select(); + } + next.parentNode.removeChild(next); + } else { + range.create(next, 0).select(); + } + } + // jump to next node for delete + else if ((temp = dom.ancestorHaveNextSibling(target)) && (temp2 = dom.hasContentAfter(temp)) && dom.isContentEditable(temp2)) { + + dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag + temp2 = dom.firstChild(temp2); + + r = range.create(temp2, 0); + r.select(); + + if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) { + if (dom.isText(temp2)) { + temp2.textContent = temp2.textContent.replace(/^\s*\S/, ''); + } else { + $.summernote.pluginEvents.delete(event, editor, layoutInfo); + } + } + } + + $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler + event.preventDefault(); + return false; +}; +$.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable, "backspace"); + + var r = range.create(); + if (!r) return; + if (!r.isContentEditable()) { + event.preventDefault(); + return false; + } + if (!r.isCollapsed()) { + if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) { + remove_table_content(r); + range.create(r.sc, dom.nodeLength(r.sc)).select(); + } else { + r = r.deleteContents(); + r.select(); + } + event.preventDefault(); + return false; + } + + var target = r.sc; + var offset = r.so; + if (target.tagName && target.childNodes[offset]) { + target = target.childNodes[offset]; + offset = 0; + } + + var node = dom.node(target); + var data = dom.merge(node, target, offset, target, offset, null, true); + data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo); + r = dom.isVoid(data.sc) ? range.createFromNode(data.sc) : range.create(data.sc, data.so); + r.select(); + target = r.sc; + offset = r.so; + if (target.tagName && target.childNodes[offset]) { + target = target.childNodes[offset]; + offset = 0; + node = dom.node(target); + } + + while (node.parentNode && !dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;} + + var contentBefore = target.textContent.slice(0,offset).match(/\S|\u00A0/); + var content = target.textContent.replace(/[ \t\r\n]+$/, ''); + var temp; + var temp2; + var prev; + + // delete media + if (dom.isImg(node) || (!contentBefore && dom.isImg(dom.hasContentBefore(node)))) { + if (!dom.isImg(node)) { + node = dom.hasContentBefore(node); + } + range.createFromNode(node).select(); + $.summernote.pluginEvents.delete(event, editor, layoutInfo); + } + // table tr td + else if (r.isOnCell() && !offset && (target === (temp = dom.ancestor(target, dom.isCell)) || target === temp.firstChild || (dom.isText(temp.firstChild) && !dom.isVisibleText(temp.firstChild) && target === temp.firstChild.nextSibling))) { + if (dom.previousElementSibling(temp)) { + var td = dom.previousElementSibling(temp); + node = td.lastChild || td; + } else { + var tr = temp.parentNode; + var prevTr = dom.previousElementSibling(tr); + if (!$(temp.parentNode).text().match(/\S|\u00A0/)) { + if (prevTr) { + node = dom.lastChild(dom.lastElementChild(prevTr)); + } else { + node = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(tr)) || $editable.get(0)); + } + $(tr).empty(); + if (!$(tr).closest('table').has('td, th').length) { + $(tr).closest('table').remove(); + } + $(tr).remove(); + range.create(node, node.textContent.length, node, node.textContent.length).select(); + } else { + node = dom.lastElementChild(prevTr).lastChild || dom.lastElementChild(prevTr); + } + } + if (dom.isBR(node)) { + range.createFromNode(node).select(); + } else { + range.create(node, dom.nodeLength(node)).select(); + } + } + // empty tag + else if (!content.length && target.tagName && dom.isRemovableEmptyNode(target)) { + if (node === $editable[0] || $.contains(node, $editable[0])) { + event.preventDefault(); + return false; + } + var before = true; + prev = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node)); + if (!dom.isContentEditable(prev)) { + before = false; + prev = dom.hasContentAfter(dom.ancestorHaveNextSibling(node)); + } + dom.removeSpace(prev.parentNode, prev, 0, prev, 0); // clean before jump for not select invisible space between 2 tag + prev = dom.lastChild(prev); + node.parentNode.removeChild(node); + range.createFromNode(prev).select(); + range.create(prev, before ? prev.textContent.length : 0).select(); + } + // normal feature if same tag and not the begin + else if (contentBefore) { + return true; + } + // merge with the previous text node + else if (dom.isText(target) && (temp = dom.hasContentBefore(target)) && (dom.isText(temp) || dom.isBR(temp))) { + return true; + } + //merge with the previous block + else if ((temp = dom.ancestorHavePreviousSibling(target)) && + dom.isMergable(temp) && + dom.isMergable(temp2 = dom.hasContentBefore(temp)) && + temp.tagName === temp2.tagName && + (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li + !dom.isNotBreakable(temp) && + !dom.isNotBreakable(temp2)) { + prev = dom.firstChild(target); + dom.autoMerge(target, true); + range.create(prev, 0).select(); + } + // jump to previous node for delete + else if ((temp = dom.ancestorHavePreviousSibling(target)) && (temp2 = dom.hasContentBefore(temp)) && dom.isContentEditable(temp2)) { + + dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag + temp2 = dom.lastChild(temp2); + + r = range.create(temp2, temp2.textContent.length, temp2, temp2.textContent.length); + r.select(); + + if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) { + if (dom.isText(temp2)) { + temp2.textContent = temp2.textContent.replace(/\S\s*$/, ''); + } else { + $.summernote.pluginEvents.backspace(event, editor, layoutInfo); + } + } + } + + r = range.create(); + if (r) { + $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler + dom.scrollIntoViewIfNeeded(r.sc.parentNode.previousElementSibling || r.sc); + } + + event.preventDefault(); + return false; +}; + +function isFormatNode(node) { + return node.tagName && options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1; +} + +$.summernote.pluginEvents.insertUnorderedList = function (event, editor, layoutInfo, sorted) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable); + + var parent; + var r = range.create(); + if (!r) return; + var node = r.sc; + while (node && node !== $editable[0]) { + + parent = node.parentNode; + if (node.tagName === (sorted ? "UL" : "OL")) { + + var ul = document.createElement(sorted ? "ol" : "ul"); + ul.className = node.className; + parent.insertBefore(ul, node); + while (node.firstChild) { + ul.appendChild(node.firstChild); + } + parent.removeChild(node); + r.select(); + return; + + } else if (node.tagName === (sorted ? "OL" : "UL")) { + + var lis = []; + for (var i=0; i<node.children.length; i++) { + lis.push(node.children[i]); + } + + if (parent.tagName === "LI") { + node = parent; + parent = node.parentNode; + _.each(lis, function (li) { + parent.insertBefore(li, node); + }); + } else { + _.each(lis, function (li) { + while (li.firstChild) { + parent.insertBefore(li.firstChild, node); + } + }); + } + + parent.removeChild(node); + r.select(); + return; + + } + node = parent; + } + + var p0 = r.sc; + while (p0 && p0.parentNode && p0.parentNode !== $editable[0] && !isFormatNode(p0)) { + p0 = p0.parentNode; + } + if (!p0) return; + var p1 = r.ec; + while (p1 && p1.parentNode && p1.parentNode !== $editable[0] && !isFormatNode(p1)) { + p1 = p1.parentNode; + } + if (!p0.parentNode || p0.parentNode !== p1.parentNode) { + return; + } + + parent = p0.parentNode; + ul = document.createElement(sorted ? "ol" : "ul"); + parent.insertBefore(ul, p0); + var childNodes = parent.childNodes; + var brs = []; + var begin = false; + for (i = 0; i < childNodes.length; i++) { + if (begin && dom.isBR(childNodes[i])) { + parent.removeChild(childNodes[i]); + i--; + } + if ((!dom.isText(childNodes[i]) && !isFormatNode(childNodes[i])) || (!ul.firstChild && childNodes[i] !== p0) || + $.contains(ul, childNodes[i]) || (dom.isText(childNodes[i]) && !childNodes[i].textContent.match(/\S|u00A0/))) { + continue; + } + begin = true; + var li = document.createElement('li'); + ul.appendChild(li); + li.appendChild(childNodes[i]); + if (li.firstChild === p1) { + break; + } + i--; + } + if (dom.isBR(childNodes[i])) { + parent.removeChild(childNodes[i]); + } + + for (i = 0; i < brs.length ; i++) { + parent.removeChild(brs[i]); + } + r.clean().select(); + event.preventDefault(); + return false; +}; +$.summernote.pluginEvents.insertOrderedList = function (event, editor, layoutInfo) { + return $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, true); +}; +$.summernote.pluginEvents.indent = function (event, editor, layoutInfo, outdent) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable); + var r = range.create(); + if (!r) return; + + var flag = false; + function indentUL(UL, start, end) { + var next; + var tagName = UL.tagName; + var node = UL.firstChild; + var ul = document.createElement(tagName); + var li = document.createElement("li"); + li.style.listStyle = "none"; + li.appendChild(ul); + + if (flag) { + flag = 1; + } + + // create and fill ul into a li + while (node) { + if (flag === 1 || node === start || $.contains(node, start)) { + flag = true; + node.parentNode.insertBefore(li, node); + } + next = dom.nextElementSibling(node); + if (flag) { + ul.appendChild(node); + } + if (node === end || $.contains(node, end)) { + flag = false; + break; + } + node = next; + } + + var temp; + var prev = dom.previousElementSibling(li); + if (prev && prev.tagName === "LI" && (temp = dom.firstElementChild(prev)) && temp.tagName === tagName && ((dom.firstElementChild(prev) || prev.firstChild) !== ul)) { + dom.doMerge(dom.firstElementChild(prev) || prev.firstChild, ul); + li = prev; + li.parentNode.removeChild(dom.nextElementSibling(li)); + } + next = dom.nextElementSibling(li); + if (next && next.tagName === "LI" && (temp = dom.firstElementChild(next)) && temp.tagName === tagName && (dom.firstElementChild(li) !== dom.firstElementChild(next))) { + dom.doMerge(dom.firstElementChild(li), dom.firstElementChild(next)); + li.parentNode.removeChild(dom.nextElementSibling(li)); + } + } + function outdenttUL(UL, start, end) { + var next; + var node = UL.firstChild; + var parent = UL.parentNode; + var li = UL.parentNode.tagName === "LI" ? UL.parentNode : UL; + var ul = UL.parentNode.tagName === "LI" ? UL.parentNode.parentNode : UL.parentNode; + start = dom.ancestor(start, dom.isLi); + end = dom.ancestor(end, dom.isLi); + + if (ul.tagName !== "UL" && ul.tagName !== "OL") return; + + // create and fill ul into a li + while (node) { + if (node === start || $.contains(node, start)) { + flag = true; + if (dom.previousElementSibling(node) && li.tagName === "LI") { + li = dom.splitTree(li, dom.prevPoint({'node': node, 'offset': 0})); + } + } + next = dom.nextElementSibling(node); + if (flag) { + ul = node.parentNode; + li.parentNode.insertBefore(node, li); + if (!ul.children.length) { + if (ul.parentNode.tagName === "LI") { + ul = ul.parentNode; + } + ul.parentNode.removeChild(ul); + } + } + + if (node === end || $.contains(node, end)) { + flag = false; + break; + } + node = next; + } + + dom.merge(parent, start, 0, end, 1, null, true); + } + function indentOther(p, start, end) { + if (p === start || $.contains(p, start) || $.contains(start, p)) { + flag = true; + } + if (flag) { + if (outdent) { + dom.outdent(p); + } else { + dom.indent(p); + } + } + if (p === end || $.contains(p, end) || $.contains(end, p)) { + flag = false; + } + } + + var ancestor = r.commonAncestor(); + var $dom = $(ancestor); + + if (!dom.isList(ancestor)) { + // to indent a selection, we indent the child nodes of the common + // ancestor that contains this selection + $dom = $(dom.node(ancestor)).children(); + } + if (!$dom.not('br').length) { + // if selection is inside a list, we indent its list items + $dom = $(dom.ancestor(r.sc, dom.isList)); + if (!$dom.length) { + // if the selection is contained in a single HTML node, we indent + // the first ancestor 'content block' (P, H1, PRE, ...) or TD + $dom = $(r.sc).closest(options.styleTags.join(',')+',td'); + } + } + + // if select tr, take the first td + $dom = $dom.map(function () { return this.tagName === "TR" ? dom.firstElementChild(this) : this; }); + + $dom.each(function () { + if (flag || $.contains(this, r.sc)) { + if (dom.isList(this)) { + if (outdent) { + outdenttUL(this, r.sc, r.ec); + } else { + indentUL(this, r.sc, r.ec); + } + } else if (isFormatNode(this) || dom.ancestor(this, dom.isCell)) { + indentOther(this, r.sc, r.ec); + } + } + }); + + if ($dom.length) { + var $parent = $dom.parent(); + + // remove text nodes between lists + var $ul = $parent.find('ul, ol'); + if (!$ul.length) { + $ul = $(dom.ancestor(r.sc, dom.isList)); + } + $ul.each(function () { + if (this.previousSibling && + this.previousSibling !== dom.previousElementSibling(this) && + !this.previousSibling.textContent.match(/\S/)) { + this.parentNode.removeChild(this.previousSibling); + } + if (this.nextSibling && + this.nextSibling !== dom.nextElementSibling(this) && + !this.nextSibling.textContent.match(/\S/)) { + this.parentNode.removeChild(this.nextSibling); + } + }); + + // merge same ul or ol + r = dom.merge($parent[0], r.sc, r.so, r.ec, r.eo, function (prev, cur) { + if (prev && dom.isList(prev) && dom.isEqual(prev, cur)) { + return true; + } + }, true); + range.create(r.sc, r.so, r.ec, r.eo).select(); + } + event.preventDefault(); + return false; +}; +$.summernote.pluginEvents.outdent = function (event, editor, layoutInfo) { + return $.summernote.pluginEvents.indent(event, editor, layoutInfo, true); +}; + +$.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTagName) { + $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, "Default"); + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable); + event.preventDefault(); + + var r = range.create(); + if (!r) { + return; + } + // select content since container (that firefox selects) may be removed + if (r.so === 0) { + r.sc = dom.firstChild(r.sc); + } + if (dom.nodeLength(r.ec) >= r.eo) { + r.ec = dom.lastChild(r.ec); + r.eo = dom.nodeLength(r.ec); + } + r = range.create(r.sc, r.so, r.ec, r.eo); + r.reRange().select(); + + if (sTagName === "blockquote" || sTagName === "pre") { + sTagName = $.summernote.core.agent.isMSIE ? '<' + sTagName + '>' : sTagName; + document.execCommand('FormatBlock', false, sTagName); + return; + } + + // fix by odoo because if you select a style in a li with no p tag all the ul is wrapped by the style tag + var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo); + for (var i=0; i<nodes.length; i++) { + if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) { + var ancestor = dom.ancestor(nodes[i], isFormatNode); + if ($(ancestor).parent().is('blockquote')) { + // firefox may wrap formatting block in blockquote + $(ancestor).unwrap(); + } + if (!ancestor) { + dom.wrap(nodes[i], sTagName); + } else if (ancestor.tagName.toLowerCase() !== sTagName) { + var tag = document.createElement(sTagName); + ancestor.parentNode.insertBefore(tag, ancestor); + dom.moveContent(ancestor, tag); + if (ancestor.className) { + tag.className = ancestor.className; + } + ancestor.parentNode.removeChild(ancestor); + } + } + } + r.select(); +}; +$.summernote.pluginEvents.removeFormat = function (event, editor, layoutInfo, value) { + var $editable = layoutInfo.editable(); + $editable.data('NoteHistory').recordUndo($editable); + var r = range.create(); + if (!r) return; + var node = range.create().sc.parentNode; + document.execCommand('removeFormat'); + document.execCommand('removeFormat'); + r = range.create(); + if (!r) return; + r = dom.merge(node, r.sc, r.so, r.ec, r.eo, null, true); + range.create(r.sc, r.so, r.ec, r.eo).select(); + event.preventDefault(); + return false; +}; + +eventHandler.modules.editor.undo = function ($popover) { + if (!$popover.attr('disabled')) $popover.data('NoteHistory').undo(); +}; +eventHandler.modules.editor.redo = function ($popover) { + if (!$popover.attr('disabled')) $popover.data('NoteHistory').redo(); +}; + +// Get color and background color of node to update recent color button +var fn_from_node = eventHandler.modules.editor.style.fromNode; +eventHandler.modules.editor.style.fromNode = function ($node) { + var styleInfo = fn_from_node.apply(this, arguments); + styleInfo['color'] = $node.css('color'); + styleInfo['background-color'] = $node.css('background-color'); + return styleInfo; +}; + +// use image toolbar if current range is on image +var fn_editor_currentstyle = eventHandler.modules.editor.currentStyle; +eventHandler.modules.editor.currentStyle = function (target) { + var styleInfo = fn_editor_currentstyle.apply(this, arguments); + // with our changes for inline editor, the targeted element could be a button of the editor + if (!styleInfo.image || !dom.isEditable(styleInfo.image)) { + styleInfo.image = undefined; + var r = range.create(); + if (r) + styleInfo.image = r.isOnImg(); + } + // Fix when the target is a link: the text-align buttons state should + // indicate the alignment of the link in the parent, not the text inside + // the link (which is not possible to customize with summernote). Summernote fixed + // this in their newest version... by just not showing the active button + // for alignments. + if (styleInfo.anchor) { + styleInfo['text-align'] = $(styleInfo.anchor).parent().css('text-align'); + } + return styleInfo; +}; + +options.fontSizes = [_t('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62]; +$.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color, bgcolor, size) { + var r = range.create(); + if (!r) return; + var startPoint = r.getStartPoint(); + var endPoint = r.getEndPoint(); + + if (r.isCollapsed() && !dom.isFont(r.sc)) { + return { + sc: startPoint.node, + so: startPoint.offset, + ec: endPoint.node, + offset: endPoint.offset + }; + } + + if (startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset]) { + startPoint.node = startPoint.node.childNodes[startPoint.offset]; + startPoint.offset = 0; + } + if (endPoint.node.tagName && endPoint.node.childNodes[endPoint.offset]) { + endPoint.node = endPoint.node.childNodes[endPoint.offset]; + endPoint.offset = 0; + } + + // get first and last point + var ancestor; + var node; + if (endPoint.offset && endPoint.offset !== dom.nodeLength(endPoint.node)) { + ancestor = dom.ancestor(endPoint.node, dom.isFont) || endPoint.node; + dom.splitTree(ancestor, endPoint); + } + if (startPoint.offset && startPoint.offset !== dom.nodeLength(startPoint.node)) { + ancestor = dom.ancestor(startPoint.node, dom.isFont) || startPoint.node; + node = dom.splitTree(ancestor, startPoint); + if (endPoint.node === startPoint.node) { + endPoint.node = node; + endPoint.offset = dom.nodeLength(node); + } + startPoint.node = node; + startPoint.offset = 0; + } + + // get list of nodes to change + var nodes = []; + dom.walkPoint(startPoint, endPoint, function (point) { + var node = point.node; + if (((dom.isText(node) && dom.isVisibleText(node)) || + (dom.isFont(node) && !dom.isVisibleText(node))) && + (node !== endPoint.node || endPoint.offset)) { + + nodes.push(point.node); + + } + }); + nodes = list.unique(nodes); + + // If ico fa + if (r.isCollapsed()) { + nodes.push(startPoint.node); + } + + // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...) + var font, $font, fonts = [], className; + var i; + if (color || bgcolor || size) { + for (i=0; i<nodes.length; i++) { + node = nodes[i]; + + font = dom.ancestor(node, dom.isFont); + if (!font) { + if (node.textContent.match(/^[ ]|[ ]$/)) { + node.textContent = node.textContent.replace(/^[ ]|[ ]$/g, '\u00A0'); + } + + font = dom.create("font"); + node.parentNode.insertBefore(font, node); + font.appendChild(node); + } + + fonts.push(font); + + className = font.className.split(/\s+/); + + var k; + if (color) { + for (k=0; k<className.length; k++) { + if (className[k].length && className[k].slice(0,5) === "text-") { + className.splice(k,1); + k--; + } + } + + if (color.indexOf('text-') !== -1) { + font.className = className.join(" ") + " " + color; + font.style.color = "inherit"; + } else { + font.className = className.join(" "); + font.style.color = color; + } + } + if (bgcolor) { + for (k=0; k<className.length; k++) { + if (className[k].length && className[k].slice(0,3) === "bg-") { + className.splice(k,1); + k--; + } + } + + if (bgcolor.indexOf('bg-') !== -1) { + font.className = className.join(" ") + " " + bgcolor; + font.style.backgroundColor = "inherit"; + } else { + font.className = className.join(" "); + font.style.backgroundColor = bgcolor; + } + } + if (size) { + font.style.fontSize = "inherit"; + if (!isNaN(size) && Math.abs(parseInt(dom.getComputedStyle(font).fontSize, 10)-size)/size > 0.05) { + font.style.fontSize = size + "px"; + } + } + } + } + + // remove empty values + // we must remove the value in 2 steps (applay inherit then remove) because some + // browser like chrome have some time an error for the rendering and/or keep inherit + for (i=0; i<fonts.length; i++) { + font = fonts[i]; + if (font.style.backgroundColor === "inherit") { + font.style.backgroundColor = ""; + } + if (font.style.color === "inherit") { + font.style.color = ""; + } + if (font.style.fontSize === "inherit") { + font.style.fontSize = ""; + } + + $font = $(font); + + if (!$font.css("color") && !$font.css("background-color") && !$font.css("font-size")) { + $font.removeAttr("style"); + } + if (!font.className.length) { + $font.removeAttr("class"); + } + } + + // select nodes to clean (to remove empty font and merge same nodes) + nodes = []; + dom.walkPoint(startPoint, endPoint, function (point) { + nodes.push(point.node); + }); + nodes = list.unique(nodes); + + function remove(node, to) { + if (node === endPoint.node) { + endPoint = dom.prevPoint(endPoint); + } + if (to) { + dom.moveContent(node, to); + } + dom.remove(node); + } + + // remove node without attributes (move content), and merge the same nodes + var className2, style, style2; + for (i=0; i<nodes.length; i++) { + node = nodes[i]; + + if ((dom.isText(node) || dom.isBR(node)) && !dom.isVisibleText(node)) { + remove(node); + nodes.splice(i,1); + i--; + continue; + } + + font = dom.ancestor(node, dom.isFont); + node = font || dom.ancestor(node, dom.isSpan); + + if (!node) { + continue; + } + + $font = $(node); + className = dom.orderClass(node); + style = dom.orderStyle(node); + + if (!className && !style) { + remove(node, node.parentNode); + nodes.splice(i,1); + i--; + continue; + } + + if (i>0 && (font = dom.ancestor(nodes[i-1], dom.isFont))) { + className2 = font.getAttribute('class'); + style2 = font.getAttribute('style'); + if (node !== font && className === className2 && style === style2) { + remove(node, font); + nodes.splice(i,1); + i--; + continue; + } + } + } + + range.create(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset).select(); +}; +$.summernote.pluginEvents.fontSize = function (event, editor, layoutInfo, value) { + var $editable = layoutInfo.editable(); + event.preventDefault(); + $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, value); + editor.afterCommand($editable); +}; +$.summernote.pluginEvents.color = function (event, editor, layoutInfo, sObjColor) { + var oColor = JSON.parse(sObjColor); + var foreColor = oColor.foreColor, backColor = oColor.backColor; + + if (foreColor) { $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, foreColor); } + if (backColor) { $.summernote.pluginEvents.backColor(event, editor, layoutInfo, backColor); } +}; +$.summernote.pluginEvents.foreColor = function (event, editor, layoutInfo, foreColor) { + var $editable = layoutInfo.editable(); + $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null); + editor.afterCommand($editable); +}; +$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor) { + var $editable = layoutInfo.editable(); + var r = range.create(); + if (!r) return; + if (r.isCollapsed() && r.isOnCell()) { + var cell = dom.ancestor(r.sc, dom.isCell); + cell.className = cell.className.replace(new RegExp('(^|\\s+)bg-[^\\s]+(\\s+|$)', 'gi'), ''); + cell.style.backgroundColor = ""; + if (backColor.indexOf('bg-') !== -1) { + cell.className += ' ' + backColor; + } else if (backColor !== 'inherit') { + cell.style.backgroundColor = backColor; + } + return; + } + $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, backColor, null); + editor.afterCommand($editable); +}; + +options.onCreateLink = function (sLinkUrl) { + if (sLinkUrl.indexOf('mailto:') === 0 || sLinkUrl.indexOf('tel:') === 0) { + sLinkUrl = sLinkUrl.replace(/^tel:([0-9]+)$/, 'tel://$1'); + } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) { + sLinkUrl = 'mailto:' + sLinkUrl; + } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl[0] !== '/' + && sLinkUrl[0] !== '#' && sLinkUrl.slice(0, 2) !== '${') { + sLinkUrl = 'http://' + sLinkUrl; + } + return sLinkUrl; +}; + +function summernote_table_scroll(event) { + var r = range.create(); + if (r && r.isOnCell()) { + $('.o_table_handler').remove(); + } +} +function summernote_table_update(oStyle) { + var r = range.create(); + if (!oStyle.range || !r || !r.isOnCell() || !r.isOnCellFirst()) { + $('.o_table_handler').remove(); + return; + } + var table = dom.ancestor(oStyle.range.sc, dom.isTable); + if (!table) { // if the editable tag is inside the table + return; + } + var $editable = $(table).closest('.o_editable'); + + $('.o_table_handler').remove(); + + var $dels = $(); + var $adds = $(); + var $tds = $('tr:first', table).children(); + $tds.each(function () { + var $td = $(this); + var pos = $td.offset(); + + var $del = $('<span class="o_table_handler fa fa-minus-square"/>').appendTo('body'); + $del.data('td', this); + $dels = $dels.add($del); + $del.css({ + left: ((pos.left + $td.outerWidth()/2)-6) + "px", + top: (pos.top-6) + "px" + }); + + var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body'); + $add.data('td', this); + $adds = $adds.add($add); + $add.css({ + left: (pos.left-6) + "px", + top: (pos.top-6) + "px" + }); + }); + + var $last = $tds.last(); + var pos = $last.offset(); + var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body'); + $adds = $adds.add($add); + $add.css({ + left: (pos.left+$last.outerWidth()-6) + "px", + top: (pos.top-6) + "px" + }); + + var $table = $(table); + $dels.data('table', table).on('mousedown', function (event) { + var td = $(this).data('td'); + $editable.data('NoteHistory').recordUndo($editable); + + var newTd; + if ($(td).siblings().length) { + var eq = $(td).index(); + $table.find('tr').each(function () { + $('> td:eq('+eq+')', this).remove(); + }); + newTd = $table.find('tr:first > td:eq('+eq+'), tr:first > td:last').first(); + } else { + var prev = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling($table[0]))); + $table.remove(); + $('.o_table_handler').remove(); + r = range.create(prev, prev.textContent.length); + r.select(); + $(r.sc).trigger('mouseup'); + return; + } + + $('.o_table_handler').remove(); + range.create(newTd[0], 0, newTd[0], 0).select(); + newTd.trigger('mouseup'); + }); + $adds.data('table', table).on('mousedown', function (event) { + var td = $(this).data('td'); + $editable.data('NoteHistory').recordUndo($editable); + + var newTd; + if (td) { + var eq = $(td).index(); + $table.find('tr').each(function () { + $('td:eq('+eq+')', this).before('<td>'+dom.blank+'</td>'); + }); + newTd = $table.find('tr:first td:eq('+eq+')'); + } else { + $table.find('tr').each(function () { + $(this).append('<td>'+dom.blank+'</td>'); + }); + newTd = $table.find('tr:first td:last'); + } + + $('.o_table_handler').remove(); + range.create(newTd[0], 0, newTd[0], 0).select(); + newTd.trigger('mouseup'); + }); + + $dels.css({ + 'position': 'absolute', + 'cursor': 'pointer', + 'background-color': '#fff', + 'color': '#ff0000' + }); + $adds.css({ + 'position': 'absolute', + 'cursor': 'pointer', + 'background-color': '#fff', + 'color': '#00ff00' + }); +} +var fn_popover_update = eventHandler.modules.popover.update; +eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) { + fn_popover_update.call(this, $popover, oStyle, isAirMode); + if ((isAirMode ? $popover : $popover.parent()).find('.note-table').length) { + summernote_table_update(oStyle); + } +}; + +var fn_attach = eventHandler.attach; +eventHandler.attach = function (oLayoutInfo, options) { + var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable'); + fn_attach.call(this, oLayoutInfo, options); + $editable.on("scroll", summernote_table_scroll); +}; +var fn_detach = eventHandler.detach; +eventHandler.detach = function (oLayoutInfo, options) { + var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable'); + fn_detach.call(this, oLayoutInfo, options); + $editable.off("scroll", summernote_table_scroll); + $('.o_table_handler').remove(); +}; + +options.icons.image.image = "file-image-o"; +$.summernote.lang['en-US'].image.image = "File / Image"; + +return $.summernote; +}); diff --git a/addons/web_editor/static/src/js/editor/transcoder.js b/addons/web_editor/static/src/js/editor/transcoder.js new file mode 100644 index 00000000000..745e8492b11 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/transcoder.js @@ -0,0 +1,395 @@ +odoo.define('web_editor.transcoder', function (require) { +'use strict'; + +var base = require('web_editor.base'); + +var rulesCache = []; + +/** + * Returns the css rules which applies on an element, tweaked so that they are + * browser/mail client ok. + * + * @param {DOMElement} a + * @returns {Object} css property name -> css property value + */ +function getMatchedCSSRules(a) { + var i, r, k; + if (!rulesCache.length) { + var sheets = document.styleSheets; + for (i = sheets.length-1 ; i >= 0 ; i--) { + var rules; + // try...catch because browser may not able to enumerate rules for cross-domain sheets + try { + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (rules) { + for (r = rules.length-1; r >= 0; r--) { + var selectorText = rules[r].selectorText; + if (selectorText && + rules[r].cssText && + selectorText !== '*' && + selectorText.indexOf(':hover') === -1 && + selectorText.indexOf(':before') === -1 && + selectorText.indexOf(':after') === -1 && + selectorText.indexOf(':active') === -1 && + selectorText.indexOf(':link') === -1 && + selectorText.indexOf('::') === -1 && + selectorText.indexOf("'") === -1) { + var st = selectorText.split(/\s*,\s*/); + for (k = 0 ; k < st.length ; k++) { + rulesCache.push({ 'selector': st[k], 'style': rules[r].style }); + } + } + } + } + } + rulesCache.reverse(); + } + + var css = []; + var style; + a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector; + for (r = 0; r < rulesCache.length; r++) { + if (a.matches(rulesCache[r].selector)) { + style = rulesCache[r].style; + if (style.parentRule) { + var style_obj = {}; + var len; + for (k = 0, len = style.length ; k < len ; k++) { + if (style[k].indexOf('animation') !== -1) { + continue; + } + style_obj[style[k]] = style[style[k].replace(/-(.)/g, function (a, b) { return b.toUpperCase(); })]; + if (new RegExp(style[k] + '\s*:[^:;]+!important' ).test(style.cssText)) { + style_obj[style[k]] += ' !important'; + } + } + rulesCache[r].style = style = style_obj; + } + css.push([rulesCache[r].selector, style]); + } + } + + function specificity(selector) { + // http://www.w3.org/TR/css3-selectors/#specificity + var a = 0; + selector = selector.replace(/#[a-z0-9_-]+/gi, function () { a++; return ''; }); + var b = 0; + selector = selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ''; }); + var c = 0; + selector = selector.replace(/(^|\s+|:+)[a-z0-9_-]+/gi, function (a) { if (a.indexOf(':not(')===-1) c++; return ''; }); + return a*100 + b*10 + c; + } + css.sort(function (a, b) { return specificity(a[0]) - specificity(b[0]); }); + + style = {}; + _.each(css, function (v,k) { + _.each(v[1], function (v,k) { + if (v && _.isString(v) && k.indexOf('-webkit') === -1 && (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1)) { + style[k] = v; + } + }); + }); + + _.each(style, function (v,k) { + if (v.indexOf('important') !== -1) { + style[k] = v.slice(0, v.length-11); + } + }); + + if (style.display === 'block') { + delete style.display; + } + + // The css generates all the attributes separately and not in simplified form. + // In order to have a better compatibility (outlook for example) we simplify the css tags. + // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none + _.each([ + {property: 'margin'}, + {property: 'padding'}, + {property: 'border', propertyEnd: '-style', defaultValue: 'none'}, + ], function (propertyInfo) { + var p = propertyInfo.property; + var e = propertyInfo.propertyEnd || ''; + var defVal = propertyInfo.defaultValue || 0; + + if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) { + if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) { + // keep => property: [top/right/bottom/left value]; + style[p+e] = style[p+'-top'+e]; + } + else { + // keep => property: [top value] [right value] [bottom value] [left value]; + style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal); + if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) { + // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value]; + delete style[p+e]; + return; + } + } + delete style[p+'-top'+e]; + delete style[p+'-right'+e]; + delete style[p+'-bottom'+e]; + delete style[p+'-left'+e]; + } + }); + + if (style['border-bottom-left-radius']) { + style['border-radius'] = style['border-bottom-left-radius']; + delete style['border-bottom-left-radius']; + delete style['border-bottom-right-radius']; + delete style['border-top-left-radius']; + delete style['border-top-right-radius']; + } + + // if the border styling is initial we remove it to simplify the css tags for compatibility. + // Also, since we do not send a css style tag, the initial value of the border is useless. + _.each(_.keys(style), function (k) { + if (k.indexOf('border') !== -1 && style[k] === 'initial') { + delete style[k]; + } + }); + + // text-decoration rule is decomposed in -line, -color and -style. This is + // however not supported by many browser/mail clients and the editor does + // not allow to change -color and -style rule anyway + if (style['text-decoration-line']) { + style['text-decoration'] = style['text-decoration-line']; + delete style['text-decoration-line']; + delete style['text-decoration-color']; + delete style['text-decoration-style']; + } + + // text-align inheritance does not seem to get past <td> elements on some + // mail clients + if (style['text-align'] === 'inherit') { + var $el = $(a).parent(); + do { + var align = $el.css('text-align'); + if (_.indexOf(['left', 'right', 'center', 'justify'], align) >= 0) { + style['text-align'] = align; + break; + } + $el = $el.parent(); + } while ($el.length && !$el.is('html')); + } + + return style; +} + +/** + * Converts font icons to images. + * + * @param {jQuery} $editable - the element in which the font icons have to be + * converted to images + */ +function fontToImg($editable) { + $editable.find('.fa').each(function () { + var $font = $(this); + var icon, content; + _.find(base.fontIcons, function (font) { + return _.find(base.getCssSelectors(font.parser), function (data) { + if ($font.is(data.selector.replace(/::?before/g, ''))) { + icon = data.names[0].split('-').shift(); + content = data.css.match(/content:\s*['"]?(.)['"]?/)[1]; + return true; + } + }); + }); + if (content) { + var color = $font.css('color').replace(/\s/g, ''); + $font.replaceWith($('<img/>', { + src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, $font.height())), + 'data-class': $font.attr('class'), + 'data-style': $font.attr('style'), + class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style + style: $font.attr('style'), + }).css({height: 'auto', width: 'auto'})); + } else { + $font.remove(); + } + }); +} + +/** + * Converts images which were the result of a font icon convertion to a font + * icon again. + * + * @param {jQuery} $editable - the element in which the images will be converted + * back to font icons + */ +function imgToFont($editable) { + $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () { + var $img = $(this); + $img.replaceWith($('<span/>', { + class: $img.data('class'), + style: $img.data('style') + })); + }); +} + +/* + * Utility function to apply function over descendants elements + * + * This is needed until the following issue of jQuery is solved: + * https://github.com./jquery/sizzle/issues/403 + * + * @param {Element} node The root Element node + * @param {Function} func The function applied over descendants + */ +function applyOverDescendants(node, func) { + node = node.firstChild; + while (node) { + if (node.nodeType === 1) { + func(node); + applyOverDescendants(node, func); + } + var $node = $(node); + if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) { + node = $(node).parents('.o_outlook_hack')[0]; + } + else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) { + node = $node.parent()[0]; + } + node = node.nextSibling; + } +} + +/** + * Converts css style to inline style (leave the classes on elements but forces + * the style they give as inline style). + * + * @param {jQuery} $editable + */ +function classToStyle($editable) { + if (!rulesCache.length) { + getMatchedCSSRules($editable[0]); + } + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = $target.attr('style') || ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + if (_.isEmpty(style)) { + $target.removeAttr('style'); + } else { + $target.attr('style', style); + } + // Apple Mail + if (node.nodeName === 'TD' && !node.childNodes.length) { + node.innerHTML = ' '; + } + + // Outlook + if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) { + var $hack = $('<table class="o_outlook_hack" style="display: inline-table;vertical-align:middle"><tr><td></td></tr></table>'); + $hack.find('td') + .attr('height', $target.outerHeight()) + .css({ + 'text-align': $target.parent().css('text-align'), + 'margin': $target.css('padding'), + 'border-radius': $target.css('border-radius'), + 'background-color': $target.css('background-color'), + }); + $target.after($hack); + $target.appendTo($hack.find('td')); + // the space add a line when it's a table but it's invisible when it's a link + node = $hack[0].previousSibling; + if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { + $(node).remove(); + } + node = $hack[0].nextSibling; + if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { + $(node).remove(); + } + } + else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { + $target.wrap('<p class="o_outlook_hack" style="text-align:center;margin:0"/>'); + } + }); +} + +/** + * Removes the inline style which is not necessary (because, for example, a + * class on an element will induce the same style). + * + * @param {jQuery} $editable + */ +function styleToClass($editable) { + // Outlook revert + $editable.find('.o_outlook_hack').each(function () { + $(this).after($('a,img', this)); + }).remove(); + + getMatchedCSSRules($editable[0]); + + var $c = $('<span/>').appendTo(document.body); + + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); + style = $target.attr('style') || ''; + _.each(css, function (v) { + style = style.replace(v, ''); + }); + style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, ''); + if (style !== '') { + $target.attr('style', style); + } else { + $target.removeAttr('style'); + } + }); + $c.remove(); +} + +/** + * Converts css display for attachment link to real image. + * Without this post process, the display depends on the css and the picture + * does not appear when we use the html without css (to send by email for e.g.) + * + * @param {jQuery} $editable + */ +function attachmentThumbnailToLinkImg($editable) { + $editable.find('a[href*="/web/content/"][data-mimetype]:empty').each(function () { + var $link = $(this); + var $img = $('<img/>') + .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, '')) + .css('height', Math.max(1, $link.height()) + 'px') + .css('width', Math.max(1, $link.width()) + 'px'); + $link.append($img); + }); +} + +/** + * Revert attachmentThumbnailToLinkImg changes + * + * @see attachmentThumbnailToLinkImg + * @param {jQuery} $editable + */ +function linkImgToAttachmentThumbnail($editable) { + $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove(); +} + +return { + fontToImg: fontToImg, + imgToFont: imgToFont, + classToStyle: classToStyle, + styleToClass: styleToClass, + attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg, + linkImgToAttachmentThumbnail: linkImgToAttachmentThumbnail, +}; +}); |
