summaryrefslogtreecommitdiff
path: root/addons/web_editor/static/src/js/editor
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static/src/js/editor
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_editor/static/src/js/editor')
-rw-r--r--addons/web_editor/static/src/js/editor/custom_colors.js0
-rw-r--r--addons/web_editor/static/src/js/editor/editor.js289
-rw-r--r--addons/web_editor/static/src/js/editor/image_processing.js335
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js816
-rw-r--r--addons/web_editor/static/src/js/editor/rte.summernote.js1280
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js2776
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js4908
-rw-r--r--addons/web_editor/static/src/js/editor/summernote.js2527
8 files changed, 12931 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/js/editor/custom_colors.js b/addons/web_editor/static/src/js/editor/custom_colors.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/custom_colors.js
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 00000000..1d6f34aa
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/editor.js
@@ -0,0 +1,289 @@
+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 summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+
+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_editable: '_onRequestEditable',
+ request_history_undo_record: '_onHistoryUndoRecordRequest',
+ request_save: '_onSaveRequest',
+ },
+
+ /**
+ * Initializes RTE and snippets menu.
+ *
+ * @constructor
+ */
+ init: function (parent, options) {
+ var self = this;
+ var res = this._super.apply(this, arguments);
+ var Editor = options.Editor || rte.Class;
+ this.rte = new Editor(this, {
+ getConfig: function ($editable) {
+ var param = self._getDefaultConfig($editable);
+ if (options.generateOptions) {
+ param = options.generateOptions(param);
+ }
+ return param;
+ },
+ saveElement: options.saveElement,
+ });
+ 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
+
+ if (options.snippets) {
+ this.snippetsMenu = new snippetsEditor.Class(this, Object.assign({
+ $el: $editable,
+ selectorEditableArea: '.o_editable',
+ }, options));
+ }
+
+ 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
+ if (self.snippetsMenu) {
+ defs.push(this.snippetsMenu.insertAfter(this.$el));
+ }
+ this.rte.editable().find('*').off('mousedown mouseup click');
+
+ return Promise.all(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 {Promise}
+ */
+ cancel: function (reload) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ if (!rte.history.getEditableHasUndo().length) {
+ resolve();
+ } else {
+ var confirm = Dialog.confirm(this, _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."), {
+ confirm_callback: resolve,
+ });
+ confirm.on('closed', self, reject);
+ }
+ }).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 {Promise}
+ */
+ save: async function (reload) {
+ var defs = [];
+ this.trigger_up('ready_to_save', {defs: defs});
+ await Promise.all(defs);
+
+ if (this.snippetsMenu) {
+ await this.snippetsMenu.cleanForSave();
+ }
+ await this.getParent().saveModifiedImages(this.rte.editable());
+ await this.rte.save();
+
+ if (reload !== false) {
+ return this._reload();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _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,
+ };
+ },
+ /**
+ * Reloads the page in non-editable mode, with the right scrolling.
+ *
+ * @private
+ * @returns {Promise} (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 new Promise(function(){});
+ },
+
+ //--------------------------------------------------------------------------
+ // 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);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRequestEditable: function (ev) {
+ ev.data.callback(this.rte.editable());
+ },
+});
+
+return {
+ Class: EditorMenuBar,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js
new file mode 100644
index 00000000..e3497634
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/image_processing.js
@@ -0,0 +1,335 @@
+odoo.define('web_editor.image_processing', function (require) {
+'use strict';
+
+// Fields returned by cropperjs 'getData' method, also need to be passed when
+// initializing the cropper to reuse the previous crop.
+const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
+const modifierFields = [
+ 'filter',
+ 'quality',
+ 'mimetype',
+ 'glFilter',
+ 'originalId',
+ 'originalSrc',
+ 'resizeWidth',
+ 'aspectRatio',
+];
+
+// webgl color filters
+const _applyAll = (result, filter, filters) => {
+ filters.forEach(f => {
+ if (f[0] === 'blend') {
+ const cv = f[1];
+ const ctx = result.getContext('2d');
+ ctx.globalCompositeOperation = f[2];
+ ctx.globalAlpha = f[3];
+ ctx.drawImage(cv, 0, 0);
+ ctx.globalCompositeOperation = 'source-over';
+ ctx.globalAlpha = 1.0;
+ } else {
+ filter.addFilter(...f);
+ }
+ });
+};
+let applyAll;
+
+const glFilters = {
+ blur: filter => filter.addFilter('blur', 10),
+
+ '1977': (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(243, 106, 188)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .3],
+ ['brightness', .1],
+ ['contrast', .1],
+ ['saturation', .3],
+ ]);
+ },
+
+ aden: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(66, 10, 14)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'darken', .2],
+ ['brightness', .2],
+ ['contrast', -.1],
+ ['saturation', -.15],
+ ['hue', 20],
+ ]);
+ },
+
+ brannan: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(161, 44, 191)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'lighten', .31],
+ ['sepia', .5],
+ ['contrast', .4],
+ ]);
+ },
+
+ earlybird: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(.2, '#D0BA8E');
+ gradient.addColorStop(1, '#1D0210');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'overlay', .2],
+ ['sepia', .2],
+ ['contrast', -.1],
+ ]);
+ },
+
+ inkwell: (filter, cv) => {
+ applyAll(filter, [
+ ['sepia', .3],
+ ['brightness', .1],
+ ['contrast', -.1],
+ ['desaturateLuminance'],
+ ]);
+ },
+
+ // Needs hue blending mode for perfect reproduction. Close enough?
+ maven: (filter, cv) => {
+ applyAll(filter, [
+ ['sepia', .25],
+ ['brightness', -.05],
+ ['contrast', -.05],
+ ['saturation', .5],
+ ]);
+ },
+
+ toaster: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(0, '#0F4E80');
+ gradient.addColorStop(1, '#3B003B');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .5],
+ ['brightness', -.1],
+ ['contrast', .5],
+ ]);
+ },
+
+ walden: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = '#CC4400';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .3],
+ ['sepia', .3],
+ ['brightness', .1],
+ ['saturation', .6],
+ ['hue', 350],
+ ]);
+ },
+
+ valencia: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = '#3A0339';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'exclusion', .5],
+ ['sepia', .08],
+ ['brightness', .08],
+ ['contrast', .08],
+ ]);
+ },
+
+ xpro: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(.4, '#E0E7E6');
+ gradient.addColorStop(1, '#2B2AA1');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'color-burn', .7],
+ ['sepia', .3],
+ ]);
+ },
+
+ custom: (filter, cv, filterOptions) => {
+ const options = Object.assign({
+ blend: 'normal',
+ filterColor: '',
+ blur: '0',
+ desaturateLuminance: '0',
+ saturation: '0',
+ contrast: '0',
+ brightness: '0',
+ sepia: '0',
+ }, JSON.parse(filterOptions || "{}"));
+ const filters = [];
+ if (options.filterColor) {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = options.filterColor;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ filters.push(['blend', cv, options.blend, 1]);
+ }
+ delete options.blend;
+ delete options.filterColor;
+ filters.push(...Object.entries(options).map(([filter, amount]) => [filter, parseInt(amount) / 100]));
+ applyAll(filter, filters);
+ },
+};
+/**
+ * Applies data-attributes modifications to an img tag and returns a dataURL
+ * containing the result. This function does not modify the original image.
+ *
+ * @param {HTMLImageElement} img the image to which modifications are applied
+ * @returns {string} dataURL of the image with the applied modifications
+ */
+async function applyModifications(img) {
+ const data = Object.assign({
+ glFilter: '',
+ filter: '#0000',
+ quality: '75',
+ }, img.dataset);
+ let {
+ width,
+ height,
+ resizeWidth,
+ quality,
+ filter,
+ mimetype,
+ originalSrc,
+ glFilter,
+ filterOptions,
+ } = data;
+ [width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s));
+ quality = parseInt(quality);
+
+ // Crop
+ const container = document.createElement('div');
+ const original = await loadImage(originalSrc);
+ container.appendChild(original);
+ await activateCropper(original, 0, data);
+ const croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
+ $(original).cropper('destroy');
+
+ // Width
+ const result = document.createElement('canvas');
+ result.width = resizeWidth || croppedImg.width;
+ result.height = croppedImg.height * result.width / croppedImg.width;
+ const ctx = result.getContext('2d');
+ ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
+
+ // GL filter
+ if (glFilter) {
+ const glf = new window.WebGLImageFilter();
+ const cv = document.createElement('canvas');
+ cv.width = result.width;
+ cv.height = result.height;
+ applyAll = _applyAll.bind(null, result);
+ glFilters[glFilter](glf, cv, filterOptions);
+ const filtered = glf.apply(result);
+ ctx.drawImage(filtered, 0, 0, filtered.width, filtered.height, 0, 0, result.width, result.height);
+ }
+
+ // Color filter
+ ctx.fillStyle = filter || '#0000';
+ ctx.fillRect(0, 0, result.width, result.height);
+
+ // Quality
+ return result.toDataURL(mimetype, quality / 100);
+}
+
+/**
+ * Loads an src into an HTMLImageElement.
+ *
+ * @param {String} src URL of the image to load
+ * @param {HTMLImageElement} [img] img element in which to load the image
+ * @returns {Promise<HTMLImageElement>} Promise that resolves to the loaded img
+ */
+function loadImage(src, img = new Image()) {
+ return new Promise((resolve, reject) => {
+ img.addEventListener('load', () => resolve(img), {once: true});
+ img.addEventListener('error', reject, {once: true});
+ img.src = src;
+ });
+}
+
+// Because cropperjs acquires images through XHRs on the image src and we don't
+// want to load big images over the network many times when adjusting quality
+// and filter, we create a local cache of the images using object URLs.
+const imageCache = new Map();
+/**
+ * Activates the cropper on a given image.
+ *
+ * @param {jQuery} $image the image on which to activate the cropper
+ * @param {Number} aspectRatio the aspectRatio of the crop box
+ * @param {DOMStringMap} dataset dataset containing the cropperDataFields
+ */
+async function activateCropper(image, aspectRatio, dataset) {
+ const src = image.getAttribute('src');
+ if (!imageCache.has(src)) {
+ const res = await fetch(src);
+ imageCache.set(src, URL.createObjectURL(await res.blob()));
+ }
+ image.src = imageCache.get(src);
+ $(image).cropper({
+ viewMode: 2,
+ dragMode: 'move',
+ autoCropArea: 1.0,
+ aspectRatio: aspectRatio,
+ data: _.mapObject(_.pick(dataset, ...cropperDataFields), value => parseFloat(value)),
+ // Can't use 0 because it's falsy and cropperjs will then use its defaults (200x100)
+ minContainerWidth: 1,
+ minContainerHeight: 1,
+ });
+ return new Promise(resolve => image.addEventListener('ready', resolve, {once: true}));
+}
+/**
+ * Marks an <img> with its attachment data (originalId, originalSrc, mimetype)
+ *
+ * @param {HTMLImageElement} img the image whose attachment data should be found
+ * @param {Function} rpc a function that can be used to make the RPC. Typically
+ * this would be passed as 'this._rpc.bind(this)' from widgets.
+ */
+async function loadImageInfo(img, rpc) {
+ const src = img.getAttribute('src');
+ // If there is a marked originalSrc, the data is already loaded.
+ if (img.dataset.originalSrc || !src) {
+ return;
+ }
+
+ const {original} = await rpc({
+ route: '/web_editor/get_image_info',
+ params: {src: src.split(/[?#]/)[0]},
+ });
+ // Check that url is local.
+ const isLocal = original && new URL(original.image_src, window.location.origin).origin === window.location.origin;
+ if (isLocal && original.image_src) {
+ img.dataset.originalId = original.id;
+ img.dataset.originalSrc = original.image_src;
+ img.dataset.mimetype = original.mimetype;
+ }
+}
+
+return {
+ applyModifications,
+ cropperDataFields,
+ activateCropper,
+ loadImageInfo,
+ loadImage,
+ removeOnImageChangeAttrs: [...cropperDataFields, ...modifierFields, 'aspectRatio'],
+};
+});
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 00000000..baded863
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/rte.js
@@ -0,0 +1,816 @@
+odoo.define('web_editor.rte', function (require) {
+'use strict';
+
+var fonts = require('wysiwyg.fonts');
+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');
+ var $tempDiv = $('<div/>', {html: oSnap.contents});
+ _.each($tempDiv.find('.o_temp_auto_element'), function (el) {
+ var $el = $(el);
+ var originalContent = $el.attr('data-temp-auto-element-original-content');
+ if (originalContent) {
+ $el.after(originalContent);
+ }
+ $el.remove();
+ });
+ $editable.html($tempDiv.html()).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, params) {
+ 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 = params && params.getConfig || this._getDefaultConfig;
+ this._saveElement = params && params.saveElement || this._saveElement;
+
+ fonts.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', function (ev) {
+ self.trigger_up('rte_change', {target: ev.target});
+
+ // Add the dirty flag to the element that changed by either adding
+ // it on the highest editable ancestor or, if there is no editable
+ // ancestor, on the element itself (that element may not be editable
+ // but if it received a content_changed event, it should be marked
+ // as dirty to allow for custom savings).
+ if (!ev.__isDirtyHandled) {
+ ev.__isDirtyHandled = true;
+
+ var el = ev.target;
+ var dirty = el.closest('.o_editable') || el;
+ dirty.classList.add('o_dirty');
+ }
+ });
+
+ $('#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) {
+ const initialActiveElement = document.activeElement;
+ const initialSelectionStart = initialActiveElement && initialActiveElement.selectionStart;
+ const initialSelectionEnd = initialActiveElement && initialActiveElement.selectionEnd;
+
+ $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);
+
+ if (initialActiveElement && initialActiveElement !== document.activeElement) {
+ initialActiveElement.focus();
+ // Range inputs don't support selection
+ if (initialActiveElement.matches('input[type=range]')) {
+ return;
+ }
+ try {
+ initialActiveElement.selectionStart = initialSelectionStart;
+ initialActiveElement.selectionEnd = initialSelectionEnd;
+ } catch (e) {
+ // The active element might be of a type that
+ // does not support selection.
+ console.log('error', e);
+ }
+ }
+ },
+ /**
+ * 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 {Promise} rejected if the save cannot be done
+ */
+ save: function (context) {
+ var self = this;
+
+ $('.o_editable')
+ .destroy()
+ .removeClass('o_editable o_is_inline_editable o_editable_date_field_linked o_editable_date_field_format_changed');
+
+ 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');
+ }).guardedCatch(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);
+ $('.o_editable.' + id)
+ .removeClass(id)
+ .popover({
+ trigger: 'hover',
+ content: response.message.data.message || '',
+ placement: 'auto top',
+ })
+ .popover('show');
+ });
+ });
+ });
+
+ return Promise.all(defs).then(function () {
+ window.onbeforeunload = null;
+ }).guardedCatch(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') === "datetime" || $editable.data('oe-type') === "date") {
+ var selector = '[data-oe-id="' + $editable.data('oe-id') + '"]';
+ selector += '[data-oe-field="' + $editable.data('oe-field') + '"]';
+ selector += '[data-oe-model="' + $editable.data('oe-model') + '"]';
+ var $linkedFieldNodes = this.editable().find(selector).addBack(selector);
+ $linkedFieldNodes.not($editable).addClass('o_editable_date_field_linked');
+ if (!$editable.hasClass('o_editable_date_field_format_changed')) {
+ $linkedFieldNodes.html($editable.data('oe-original-with-format'));
+ $linkedFieldNodes.addClass('o_editable_date_field_format_changed');
+ }
+ }
+ 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();
+ }
+ if ($editable.data('oe-type') === "image") {
+ $editable.attr('contenteditable', false);
+ $editable.find('img').attr('contenteditable', true);
+ }
+ },
+ /**
+ * 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) {
+ var viewID = $el.data('oe-id');
+ if (!viewID) {
+ return Promise.resolve();
+ }
+
+ return this._rpc({
+ model: 'ir.ui.view',
+ method: 'save',
+ args: [
+ viewID,
+ 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');
+ var isLink = $target.is('a');
+
+ if (this && this.$last && this.$last.length && this.$last[0] !== $target[0]) {
+ $('.o_editable_date_field_linked').removeClass('o_editable_date_field_linked');
+ }
+ if (!$editable.length || (!isLink && $.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 (isLink && !$target.closest('.o_not_editable').length) {
+ /**
+ * 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.
+ */
+ let hasContentEditable = $target.attr('contenteditable');
+ $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;
+ if (!hasContentEditable) {
+ $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();
+ // In some special cases, we need to clear the timeout.
+ var lastTimerId = _.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;
+ // for modal dialogs (eg newsletter popup), when we close the dialog, the modal is
+ // destroyed immediately and so after the delayed execution due to timeout, dialog will
+ // not be available, leading to trace-back, so we need to clearTimeout for the dialogs.
+ if ($destroy.hasClass('modal-body')) {
+ clearTimeout(lastTimerId);
+ }
+ }
+
+ 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 && 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';
+
+// These colors are already normalized as per normalizeCSSColor in web.Colorpicker
+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 00000000..76d86d47
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -0,0 +1,1280 @@
+odoo.define('web_editor.rte.summernote', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+const concurrency = require('web.concurrency');
+var core = require('web.core');
+// Use the top window's core.bus for dialog events so that they take the whole window
+// instead of being confined to an iframe. This means that the event triggered on
+// the bus by summernote in an iframe will be caught by the wysiwyg's SummernoteManager
+// outside the iframe.
+const topBus = window.top.odoo.__DEBUG__.services['web.core'].bus;
+const {ColorpickerWidget} = require('web.Colorpicker');
+var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
+var mixins = require('web.mixins');
+var fonts = require('wysiwyg.fonts');
+var rte = require('web_editor.rte');
+var ServicesMixin = require('web.ServicesMixin');
+var weWidgets = require('wysiwyg.widgets');
+
+var _t = core._t;
+
+// 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;
+
+// Summernote uses execCommand and, worth, obsolete queryCommandState function
+// to customize the edited content. Here we try to hack the function to solve
+// some problems by making the DOM and style easier to understand for the
+// base function for the duration of their executions. This won't obviously
+// solves all problems but this is an improvement while waiting for the new
+// Odoo editor coming in future versions.
+function protectCommand(callback) {
+ return function () {
+ var rng = range.create();
+ var $sc = (rng && rng.sc) ? $(rng.sc).parents(':o_editable').last() : $();
+ var $ec = (rng && rng.ec) ? $(rng.ec).parents(':o_editable').last() : $();
+ $sc.addClass('o_we_command_protector');
+ $ec.addClass('o_we_command_protector');
+ var restore = function () {
+ $sc.removeClass('o_we_command_protector');
+ $ec.removeClass('o_we_command_protector');
+ };
+ var result;
+ try {
+ result = callback.apply(this, arguments);
+ } catch (err) {
+ restore();
+ throw err;
+ }
+ restore();
+ return result;
+ };
+}
+document.execCommand = protectCommand(document.execCommand);
+document.queryCommandState = protectCommand(document.queryCommandState);
+
+var tplButton = renderer.getTemplate().button;
+var tplIconButton = renderer.getTemplate().iconButton;
+var tplDropdown = renderer.getTemplate().dropdown;
+
+const processAndApplyColor = function (target, eventName, color, preview) {
+ if (!color) {
+ color = 'inherit';
+ } else if (!ColorpickerWidget.isCSSColor(color)) {
+ color = (eventName === "foreColor" ? 'text-' : 'bg-') + color;
+ }
+ var layoutInfo = dom.makeLayoutInfo(target);
+ $.summernote.pluginEvents[eventName](undefined, eventHandler.modules.editor, layoutInfo, color, preview);
+};
+// Update and change the popovers content, and add history button
+renderer.createPalette = function ($container, options) {
+ const $dropdownContent = $container.find(".colorPalette");
+ // The editor's root widget can be website or web's root widget and cannot be properly retrieved...
+ const parent = odoo.__DEBUG__.services['root.widget'];
+ _.each($dropdownContent, elem => {
+ const eventName = elem.dataset.eventName;
+ let colorpicker = null;
+ const mutex = new concurrency.MutexedDropPrevious();
+ const $dropdown = $(elem).closest('.btn-group, .dropdown');
+ let manualOpening = false;
+ // Prevent dropdown closing on colorpicker click
+ $dropdown.on('hide.bs.dropdown', ev => {
+ return !(ev.clickEvent && ev.clickEvent.originalEvent && ev.clickEvent.originalEvent.__isColorpickerClick);
+ });
+ $dropdown.on('show.bs.dropdown', () => {
+ if (manualOpening) {
+ return true;
+ }
+ mutex.exec(() => {
+ const oldColorpicker = colorpicker;
+ const hookEl = oldColorpicker ? oldColorpicker.el : elem;
+
+ const r = range.create();
+ const targetNode = r.sc;
+ const targetElement = targetNode.nodeType === Node.ELEMENT_NODE ? targetNode : targetNode.parentNode;
+ colorpicker = new ColorPaletteWidget(parent, {
+ excluded: ['transparent_grayscale'],
+ $editable: rte.Class.prototype.editable(), // Our parent is the root widget, we can't retrieve the editable section from it...
+ selectedColor: $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'),
+ });
+ colorpicker.on('custom_color_picked color_picked', null, ev => {
+ processAndApplyColor(ev.data.target, eventName, ev.data.color);
+ });
+ colorpicker.on('color_hover color_leave', null, ev => {
+ processAndApplyColor(ev.data.target, eventName, ev.data.color, true);
+ });
+ colorpicker.on('enter_key_color_colorpicker', null, () => {
+ $dropdown.children('.dropdown-toggle').dropdown('hide');
+ });
+ return colorpicker.replace(hookEl).then(() => {
+ if (oldColorpicker) {
+ oldColorpicker.destroy();
+ }
+ manualOpening = true;
+ $dropdown.children('.dropdown-toggle').dropdown('show');
+ manualOpening = false;
+ });
+ });
+ return false;
+ });
+ });
+};
+
+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('.note-fore-color-preview > button > .caret').css('border-bottom-color', $(oStyle.image).css('color'));
+ $container.find('.note-back-color-preview > button > .caret').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="cropImage"]').addClass('d-none');
+
+ $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('button[data-event="cropImage"]').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('.note-fore-color-preview > button > .caret').css('border-bottom-color', oStyle.color);
+ $container.find('.note-back-color-preview > button > .caret').css('border-bottom-color', oStyle['background-color']);
+ }
+};
+
+var fn_toolbar_boutton_update = eventHandler.modules.toolbar.button.update;
+eventHandler.modules.toolbar.button.update = function ($container, oStyle) {
+ fn_toolbar_boutton_update.call(this, $container, oStyle);
+
+ $container.find('button[data-event="insertUnorderedList"]').toggleClass("active", $(oStyle.ancestors).is('ul:not(.o_checklist)'));
+ $container.find('button[data-event="insertOrderedList"]').toggleClass("active", $(oStyle.ancestors).is('ol'));
+ $container.find('button[data-event="insertCheckList"]').toggleClass("active", $(oStyle.ancestors).is('ul.o_checklist'));
+};
+
+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(/&quot;/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();
+ }
+
+ const $externalHistoryButtons = $('.o_we_external_history_buttons');
+ if ($externalHistoryButtons.length) {
+ const $noteHistory = $('.note-history');
+ $noteHistory.addClass('d-none');
+ $externalHistoryButtons.find(':first-child').prop('disabled', $noteHistory.find('[data-event=undo]').prop('disabled'));
+ $externalHistoryButtons.find(':last-child').prop('disabled', $noteHistory.find('[data-event=redo]').prop('disabled'));
+ }
+ $popover.trigger('summernote_popover_update_call');
+};
+
+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 commonAncestor = linkInfo.range.commonAncestor();
+ if (commonAncestor && commonAncestor.closest) {
+ var link = commonAncestor.closest('a');
+ linkInfo.className = link && link.className;
+ }
+
+ var def = new $.Deferred();
+ topBus.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;
+};
+var originalShowImageDialog = eventHandler.modules.imageDialog.showImageDialog;
+eventHandler.modules.imageDialog.showImageDialog = function ($editable) {
+ var options = $editable.closest('.o_editable, .note-editor').data('options');
+ if (options.disableFullMediaDialog) {
+ return originalShowImageDialog.apply(this, arguments);
+ }
+ 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];
+ topBus.trigger('media_dialog_demand', {
+ $editable: $editable,
+ media: media,
+ options: {
+ onUpload: $editable.data('callbacks').onUpload,
+ noVideos: options && options.noVideos,
+ },
+ onSave: function (newMedia) {
+ if (!newMedia) {
+ return;
+ }
+ if (media) {
+ $(media).replaceWith(newMedia);
+ } else {
+ r.insertNode(newMedia);
+ }
+ },
+ });
+ return new $.Deferred().reject();
+};
+$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ topBus.trigger('alt_dialog_demand', {
+ $editable: $editable,
+ media: $selection.data('target'),
+ });
+};
+$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ topBus.trigger('crop_image_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<fonts.fontIcons.length; k++) {
+ if (_.intersection(fonts.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) {
+ 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
+ oLayoutInfo.editor().find('.note-image-dialog').on('click', '.note-image-input', function (e) {
+ e.stopPropagation(); // let browser default happen for image file input
+ });
+
+ /**
+ * 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, ServicesMixin, {
+ /**
+ * @constructor
+ */
+ init: function (parent) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+
+ topBus.on('alt_dialog_demand', this, this._onAltDialogDemand);
+ topBus.on('crop_image_demand', this, this._onCropImageDemand);
+ topBus.on('link_dialog_demand', this, this._onLinkDialogDemand);
+ topBus.on('media_dialog_demand', this, this._onMediaDialogDemand);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.EventDispatcherMixin.destroy.call(this);
+
+ topBus.off('alt_dialog_demand', this, this._onAltDialogDemand);
+ topBus.off('crop_image_demand', this, this._onCropImageDemand);
+ topBus.off('link_dialog_demand', this, this._onLinkDialogDemand);
+ topBus.off('media_dialog_demand', this, this._onMediaDialogDemand);
+ },
+
+ /**
+ * Create modified image attachments.
+ *
+ * @param {jQuery} $editable
+ * @returns {Promise}
+ */
+ saveModifiedImages: function ($editable) {
+ const defs = _.map($editable, async editableEl => {
+ const {oeModel: resModel, oeId: resId} = editableEl.dataset;
+ const proms = [...editableEl.querySelectorAll('.o_modified_image_to_save')].map(async el => {
+ const isBackground = !el.matches('img');
+ el.classList.remove('o_modified_image_to_save');
+ // Modifying an image always creates a copy of the original, even if
+ // it was modified previously, as the other modified image may be used
+ // elsewhere if the snippet was duplicated or was saved as a custom one.
+ const newAttachmentSrc = await this._rpc({
+ route: `/web_editor/modify_image/${el.dataset.originalId}`,
+ params: {
+ res_model: resModel,
+ res_id: parseInt(resId),
+ data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1],
+ },
+ });
+ if (isBackground) {
+ $(el).css('background-image', `url('${newAttachmentSrc}')`);
+ delete el.dataset.bgSrc;
+ } else {
+ el.setAttribute('src', newAttachmentSrc);
+ }
+ });
+ return Promise.all(proms);
+ });
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // 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.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 crop an image is received on the bus.
+ *
+ * @private
+ * @param {Object} data
+ */
+ _onCropImageDemand: function (data) {
+ if (data.__alreadyDone) {
+ return;
+ }
+ data.__alreadyDone = true;
+ new weWidgets.ImageCropWidget(this, data.media)
+ .appendTo(data.$editable.parent());
+ },
+ /**
+ * 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;
+
+ const model = data.$editable.data('oe-model');
+ const field = data.$editable.data('oe-field');
+ const type = data.$editable.data('oe-type');
+ var mediaDialog = new weWidgets.MediaDialog(this,
+ _.extend({
+ res_model: model,
+ res_id: data.$editable.data('oe-id'),
+ domain: data.$editable.data('oe-media-domain'),
+ useMediaLibrary: field && (model === 'ir.ui.view' && field === 'arch' || type === 'html'),
+ }, data.options),
+ data.media
+ );
+ if (data.onSave) {
+ mediaDialog.on('save', this, data.onSave);
+ }
+ if (data.onCancel) {
+ mediaDialog.on('cancel', this, data.onCancel);
+ }
+ mediaDialog.open();
+ },
+});
+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 00000000..dc232e1d
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -0,0 +1,2776 @@
+odoo.define('web_editor.snippet.editor', function (require) {
+'use strict';
+
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var Widget = require('web.Widget');
+var options = require('web_editor.snippets.options');
+var Wysiwyg = require('web_editor.wysiwyg');
+const {ColorPaletteWidget} = require('web_editor.ColorPalette');
+const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js');
+const {getCSSVariableValue} = require('web_editor.utils');
+
+var _t = core._t;
+
+var globalSelector = {
+ closest: () => $(),
+ all: () => $(),
+ is: () => 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_remove': '_onRemoveClick',
+ 'wheel': '_onMouseWheel',
+ },
+ custom_events: {
+ 'option_update': '_onOptionUpdate',
+ 'user_value_widget_request': '_onUserValueWidgetRequest',
+ 'snippet_option_update': '_onSnippetOptionUpdate', // TODO remove me in master
+ 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
+ },
+ layoutElementsSelector: [
+ '.o_we_shape',
+ '.o_we_bg_filter',
+ ].join(','),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Element} target
+ * @param {Object} templateOptions
+ * @param {jQuery} $editable
+ * @param {Object} options
+ */
+ init: function (parent, target, templateOptions, $editable, options) {
+ this._super.apply(this, arguments);
+ this.options = options;
+ this.$editable = $editable;
+ this.ownerDocument = this.$editable[0].ownerDocument;
+ this.$body = $(this.ownerDocument.body);
+ this.$target = $(target);
+ this.$target.data('snippet-editor', this);
+ this.templateOptions = templateOptions;
+ this.isTargetParentEditable = false;
+ this.isTargetMovable = false;
+ this.$scrollingElement = $().getScrollingElement();
+
+ this.__isStarted = new Promise(resolve => {
+ this.__isStartedResolveFunc = resolve;
+ });
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var defs = [this._super.apply(this, arguments)];
+
+ // Initialize the associated options (see snippets.options.js)
+ defs.push(this._initializeOptions());
+ var $customize = this._customize$Elements[this._customize$Elements.length - 1];
+
+ this.isTargetParentEditable = this.$target.parent().is(':o_editable');
+ this.isTargetMovable = this.isTargetParentEditable && this.isTargetMovable;
+ this.isTargetRemovable = this.isTargetParentEditable && !this.$target.parent().is('[data-oe-type="image"]');
+
+ // Initialize move/clone/remove buttons
+ if (this.isTargetMovable) {
+ this.dropped = false;
+ const smoothScrollOptions = this.options.getScrollOptions({
+ jQueryDraggableOptions: {
+ cursorAt: {
+ left: 10,
+ top: 10
+ },
+ handle: '.o_move_handle',
+ helper: () => {
+ var $clone = this.$el.clone().css({width: '24px', height: '24px', border: 0});
+ $clone.appendTo(this.$body).removeClass('d-none');
+ return $clone;
+ },
+ start: this._onDragAndDropStart.bind(this),
+ stop: (...args) => {
+ // Delay our stop handler so that some summernote handlers
+ // which occur on mouseup (and are themself delayed) are
+ // executed first (this prevents the library to crash
+ // because our stop handler may change the DOM).
+ setTimeout(() => {
+ this._onDragAndDropStop(...args);
+ }, 0);
+ },
+ },
+ });
+ this.draggableComponent = new SmoothScrollOnDrag(this, this.$el, $().getScrollingElement(), smoothScrollOptions);
+ } else {
+ this.$('.o_overlay_move_options').addClass('d-none');
+ $customize.find('.oe_snippet_clone').addClass('d-none');
+ }
+
+ if (!this.isTargetRemovable) {
+ this.$el.add($customize).find('.oe_snippet_remove').addClass('d-none');
+ }
+
+ var _animationsCount = 0;
+ var postAnimationCover = _.throttle(() => this.cover(), 100);
+ this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', () => {
+ // We cannot rely on the fact each transition/animation start will
+ // trigger a transition/animation end as the element may be removed
+ // from the DOM before or it could simply be an infinite animation.
+ //
+ // By simplicity, for each start, we add a delayed operation that
+ // will decrease the animation counter after a fixed duration and
+ // do the post animation cover if none is registered anymore.
+ _animationsCount++;
+ setTimeout(() => {
+ if (!--_animationsCount) {
+ postAnimationCover();
+ }
+ }, 500); // This delay have to be huge enough to take care of long
+ // animations which will not trigger an animation end event
+ // but if it is too small for some, this is the job of the
+ // animation creator to manually ask for a re-cover
+ });
+ // On top of what is explained above, do the post animation cover for
+ // each detected transition/animation end so that the user does not see
+ // a flickering when not needed.
+ this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', postAnimationCover);
+
+ return Promise.all(defs).then(() => {
+ this.__isStartedResolveFunc(this);
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ // Before actually destroying a snippet editor, notify the parent
+ // about it so that it can update its list of alived snippet editors.
+ this.trigger_up('snippet_editor_destroyed');
+
+ this._super(...arguments);
+ this.$target.removeData('snippet-editor');
+ this.$target.off('.snippet_editor');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Checks whether the snippet options are shown or not.
+ *
+ * @returns {boolean}
+ */
+ areOptionsShown: function () {
+ const lastIndex = this._customize$Elements.length - 1;
+ return !!this._customize$Elements[lastIndex].parent().length;
+ },
+ /**
+ * Notifies all the associated snippet options that the snippet has just
+ * been dropped in the page.
+ */
+ buildSnippet: async function () {
+ for (var i in this.styles) {
+ this.styles[i].onBuilt();
+ }
+ await this.toggleTargetVisibility(true);
+ },
+ /**
+ * Notifies all the associated snippet options that the template which
+ * contains the snippet is about to be saved.
+ */
+ cleanForSave: async function () {
+ if (this.isDestroyed()) {
+ return;
+ }
+ await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible'));
+ const proms = _.map(this.styles, option => {
+ return option.cleanForSave();
+ });
+ await Promise.all(proms);
+ },
+ /**
+ * Closes all widgets of all options.
+ */
+ closeWidgets: function () {
+ if (!this.styles || !this.areOptionsShown()) {
+ return;
+ }
+ Object.keys(this.styles).forEach(key => {
+ this.styles[key].closeWidgets();
+ });
+ },
+ /**
+ * Makes the editor overlay cover the associated snippet.
+ */
+ cover: function () {
+ if (!this.isShown() || !this.$target.length) {
+ return;
+ }
+
+ const $modal = this.$target.find('.modal');
+ const $target = $modal.length ? $modal : this.$target;
+ const targetEl = $target[0];
+
+ // Check first if the target is still visible, otherwise we have to
+ // hide it. When covering all element after scroll for instance it may
+ // have been hidden (part of an affixed header for example) or it may
+ // be outside of the viewport (the whole header during an effect for
+ // example).
+ const rect = targetEl.getBoundingClientRect();
+ const vpWidth = window.innerWidth || document.documentElement.clientWidth;
+ const vpHeight = window.innerHeight || document.documentElement.clientHeight;
+ const isInViewport = (
+ rect.bottom > -0.1 &&
+ rect.right > -0.1 &&
+ (vpHeight - rect.top) > -0.1 &&
+ (vpWidth - rect.left) > -0.1
+ );
+ const hasSize = ( // :visible not enough for images
+ Math.abs(rect.bottom - rect.top) > 0.01 &&
+ Math.abs(rect.right - rect.left) > 0.01
+ );
+ if (!isInViewport || !hasSize || !this.$target.is(`:visible`)) {
+ this.toggleOverlayVisibility(false);
+ return;
+ }
+
+ // Now cover the element
+ const offset = $target.offset();
+ var manipulatorOffset = this.$el.parent().offset();
+ offset.top -= manipulatorOffset.top;
+ offset.left -= manipulatorOffset.left;
+ this.$el.css({
+ width: $target.outerWidth(),
+ left: offset.left,
+ top: offset.top,
+ });
+ this.$('.o_handles').css('height', $target.outerHeight());
+
+ const editableOffsetTop = this.$editable.offset().top - manipulatorOffset.top;
+ this.$el.toggleClass('o_top_cover', offset.top - editableOffsetTop < 25);
+ },
+ /**
+ * 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.
+ */
+ getName: function () {
+ if (this.$target.data('name') !== undefined) {
+ return this.$target.data('name');
+ }
+ if (this.$target.is('img')) {
+ return _t("Image");
+ }
+ if (this.$target.parent('.row').length) {
+ return _t("Column");
+ }
+ return _t("Block");
+ },
+ /**
+ * @return {boolean}
+ */
+ isShown: function () {
+ return this.$el && this.$el.parent().length && this.$el.hasClass('oe_active');
+ },
+ /**
+ * @returns {boolean}
+ */
+ isSticky: function () {
+ return this.$el && this.$el.hasClass('o_we_overlay_sticky');
+ },
+ /**
+ * @returns {boolean}
+ */
+ isTargetVisible: function () {
+ return (this.$target[0].dataset.invisible !== '1');
+ },
+ /**
+ * Removes the associated snippet from the DOM and destroys the associated
+ * editor (itself).
+ *
+ * @returns {Promise}
+ */
+ removeSnippet: async function () {
+ this.toggleOverlay(false);
+ await this.toggleOptions(false);
+ // If it is an invisible element, we must close it before deleting it (e.g. modal)
+ await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible'));
+
+ await new Promise(resolve => {
+ 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();
+ }
+ resolve();
+ },
+ });
+ });
+
+ this.trigger_up('go_to_parent', {$snippet: this.$target});
+ var $parent = this.$target.parent();
+ this.$target.find('*').addBack().tooltip('dispose');
+ this.$target.remove();
+ this.$el.remove();
+
+ var node = $parent[0];
+ if (node && node.firstChild) {
+ if (!node.firstChild.tagName && node.firstChild.textContent === ' ') {
+ node.removeChild(node.firstChild);
+ }
+ }
+
+ if ($parent.closest(':data("snippet-editor")').length) {
+ const isEmptyAndRemovable = ($el, editor) => {
+ editor = editor || $el.data('snippet-editor');
+ const isEmpty = $el.text().trim() === ''
+ && $el.children().toArray().every(el => {
+ // Consider layout-only elements (like bg-shapes) as empty
+ return el.matches(this.layoutElementsSelector);
+ });
+ return isEmpty && !$el.hasClass('oe_structure')
+ && (!editor || editor.isTargetParentEditable);
+ };
+
+ var editor = $parent.data('snippet-editor');
+ while (!editor) {
+ var $nextParent = $parent.parent();
+ if (isEmptyAndRemovable($parent)) {
+ $parent.remove();
+ }
+ $parent = $nextParent;
+ editor = $parent.data('snippet-editor');
+ }
+ if (isEmptyAndRemovable($parent, editor)) {
+ // TODO maybe this should be part of the actual Promise being
+ // returned by the function ?
+ setTimeout(() => editor.removeSnippet());
+ }
+ }
+
+ // clean editor if they are image or table in deleted content
+ this.$body.find('.note-control-selection').hide();
+ this.$body.find('.o_table_handler').remove();
+
+ this.trigger_up('snippet_removed');
+ this.destroy();
+ $parent.trigger('content_changed');
+ // TODO Page content changed, some elements may need to be adapted
+ // according to it. While waiting for a better way to handle that this
+ // window trigger will handle most cases.
+ $(window).trigger('resize');
+ },
+ /**
+ * Displays/Hides the editor overlay.
+ *
+ * @param {boolean} show
+ * @param {boolean} [previewMode=false]
+ */
+ toggleOverlay: function (show, previewMode) {
+ if (!this.$el) {
+ return;
+ }
+
+ if (previewMode) {
+ // In preview mode, the sticky classes are left untouched, we only
+ // add/remove the preview class when toggling/untoggling
+ this.$el.toggleClass('o_we_overlay_preview', show);
+ } else {
+ // In non preview mode, the preview class is always removed, and the
+ // sticky class is added/removed when toggling/untoggling
+ this.$el.removeClass('o_we_overlay_preview');
+ this.$el.toggleClass('o_we_overlay_sticky', show);
+ }
+
+ // Show/hide overlay in preview mode or not
+ this.$el.toggleClass('oe_active', show);
+ this.cover();
+ },
+ /**
+ * Displays/Hides the editor (+ parent) options and call onFocus/onBlur if
+ * necessary.
+ *
+ * @param {boolean} show
+ * @returns {Promise}
+ */
+ async toggleOptions(show) {
+ if (!this.$el) {
+ return;
+ }
+
+ if (this.areOptionsShown() === show) {
+ return;
+ }
+ // TODO should update the panel after the items have been updated
+ this.trigger_up('update_customize_elements', {
+ customize$Elements: show ? this._customize$Elements : [],
+ });
+ // All onFocus before all ui updates as the onFocus of an option might
+ // affect another option (like updating the $target)
+ const editorUIsToUpdate = [];
+ const focusOrBlur = show
+ ? (editor, options) => {
+ for (const opt of options) {
+ opt.onFocus();
+ }
+ editorUIsToUpdate.push(editor);
+ }
+ : (editor, options) => {
+ for (const opt of options) {
+ opt.onBlur();
+ }
+ };
+ for (const $el of this._customize$Elements) {
+ const editor = $el.data('editor');
+ const styles = _.chain(editor.styles)
+ .values()
+ .sortBy('__order')
+ .value();
+ // TODO ideally: allow async parts in onFocus/onBlur
+ focusOrBlur(editor, styles);
+ }
+ await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUI()));
+ await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUIVisibility()));
+ },
+ /**
+ * @param {boolean} [show]
+ * @returns {Promise<boolean>}
+ */
+ toggleTargetVisibility: async function (show) {
+ show = this._toggleVisibilityStatus(show);
+ var styles = _.values(this.styles);
+ const proms = _.sortBy(styles, '__order').map(style => {
+ return show ? style.onTargetShow() : style.onTargetHide();
+ });
+ await Promise.all(proms);
+ return show;
+ },
+ /**
+ * @param {boolean} [show=false]
+ */
+ toggleOverlayVisibility: function (show) {
+ if (this.$el && !this.scrollingTimeout) {
+ this.$el.toggleClass('o_overlay_hidden', !show && this.isShown());
+ }
+ },
+ /**
+ * Updates the UI of all the options according to the status of their
+ * associated editable DOM. This does not take care of options *visibility*.
+ * For that @see updateOptionsUIVisibility, which should called when the UI
+ * is up-to-date thanks to the function here, as the visibility depends on
+ * the UI's status.
+ *
+ * @returns {Promise}
+ */
+ async updateOptionsUI() {
+ const proms = Object.values(this.styles).map(opt => {
+ return opt.updateUI({noVisibility: true});
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Updates the visibility of the UI of all the options according to the
+ * status of their associated dependencies and related editable DOM status.
+ *
+ * @returns {Promise}
+ */
+ async updateOptionsUIVisibility() {
+ const proms = Object.values(this.styles).map(opt => {
+ return opt.updateUIVisibility();
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Clones the current snippet.
+ *
+ * @private
+ * @param {boolean} recordUndo
+ */
+ clone: async function (recordUndo) {
+ this.trigger_up('snippet_will_be_cloned', {$target: this.$target});
+
+ var $clone = this.$target.clone(false);
+
+ if (recordUndo) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ }
+
+ this.$target.after($clone);
+ await new Promise(resolve => {
+ 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)),
+ });
+ }
+ resolve();
+ },
+ });
+ });
+ this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target});
+
+ $clone.trigger('content_changed');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiates the snippet's options.
+ *
+ * @private
+ */
+ _initializeOptions: function () {
+ this._customize$Elements = [];
+ this.styles = {};
+ this.selectorSiblings = [];
+ this.selectorChildren = [];
+
+ var $element = this.$target.parent();
+ while ($element.length) {
+ var parentEditor = $element.data('snippet-editor');
+ if (parentEditor) {
+ this._customize$Elements = this._customize$Elements
+ .concat(parentEditor._customize$Elements);
+ break;
+ }
+ $element = $element.parent();
+ }
+
+ var $optionsSection = $(core.qweb.render('web_editor.customize_block_options_section', {
+ name: this.getName(),
+ })).data('editor', this);
+ const $optionsSectionBtnGroup = $optionsSection.find('we-top-button-group');
+ $optionsSectionBtnGroup.contents().each((i, node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ node.parentNode.removeChild(node);
+ }
+ });
+ $optionsSection.on('mouseenter', this._onOptionsSectionMouseEnter.bind(this));
+ $optionsSection.on('mouseleave', this._onOptionsSectionMouseLeave.bind(this));
+ $optionsSection.on('click', 'we-title > span', this._onOptionsSectionClick.bind(this));
+ $optionsSection.on('click', '.oe_snippet_clone', this._onCloneClick.bind(this));
+ $optionsSection.on('click', '.oe_snippet_remove', this._onRemoveClick.bind(this));
+ this._customize$Elements.push($optionsSection);
+
+ // TODO get rid of this when possible (made as a fix to support old
+ // theme options)
+ this.$el.data('$optionsSection', $optionsSection);
+
+ var i = 0;
+ var defs = _.map(this.templateOptions, val => {
+ if (!val.selector.is(this.$target)) {
+ return;
+ }
+ if (val['drop-near']) {
+ this.selectorSiblings.push(val['drop-near']);
+ }
+ if (val['drop-in']) {
+ this.selectorChildren.push(val['drop-in']);
+ }
+
+ var optionName = val.option;
+ var option = new (options.registry[optionName] || options.Class)(
+ this,
+ val.$el.children(),
+ val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target,
+ this.$el,
+ _.extend({
+ optionName: optionName,
+ snippetName: this.getName(),
+ }, val.data),
+ this.options
+ );
+ var key = optionName || _.uniqueId('option');
+ if (this.styles[key]) {
+ // If two snippet options use the same option name (and so use
+ // the same JS option), store the subsequent ones with a unique
+ // ID (TODO improve)
+ key = _.uniqueId(key);
+ }
+ this.styles[key] = option;
+ option.__order = i++;
+
+ if (option.forceNoDeleteButton) {
+ this.$el.add($optionsSection).find('.oe_snippet_remove').addClass('d-none');
+ }
+
+ return option.appendTo(document.createDocumentFragment());
+ });
+
+ this.isTargetMovable = (this.selectorSiblings.length > 0 || this.selectorChildren.length > 0);
+
+ this.$el.find('[data-toggle="dropdown"]').dropdown();
+
+ return Promise.all(defs).then(() => {
+ const options = _.sortBy(this.styles, '__order');
+ options.forEach(option => {
+ if (option.isTopOption) {
+ $optionsSectionBtnGroup.prepend(option.$el);
+ } else {
+ $optionsSection.append(option.$el);
+ }
+ });
+ $optionsSection.toggleClass('d-none', options.length === 0);
+ });
+ },
+ /**
+ * @private
+ * @param {boolean} [show]
+ */
+ _toggleVisibilityStatus: function (show) {
+ if (show === undefined) {
+ show = !this.isTargetVisible();
+ }
+ if (show) {
+ delete this.$target[0].dataset.invisible;
+ } else {
+ this.$target[0].dataset.invisible = '1';
+ }
+ return show;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the 'clone' button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCloneClick: function (ev) {
+ ev.preventDefault();
+ this.clone(true);
+ },
+ /**
+ * 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,
+ });
+
+ this.$body.addClass('move-important');
+
+ this.$editable.find('.oe_drop_zone').droppable({
+ over: function () {
+ if (self.dropped) {
+ self.$target.detach();
+ $('.oe_drop_zone').removeClass('invisible');
+ }
+ self.dropped = true;
+ $(this).first().after(self.$target).addClass('invisible');
+ },
+ out: function () {
+ var prev = self.$target.prev();
+ if (this === prev[0]) {
+ self.dropped = false;
+ self.$target.detach();
+ $(this).removeClass('invisible');
+ }
+ },
+ });
+
+ // If a modal is open, the scroll target must be that modal
+ const $openModal = self.$editable.find('.modal:visible');
+ self.draggableComponent.$scrollTarget = $openModal.length ? $openModal : self.$scrollingElement;
+
+ // Trigger a scroll on the draggable element so that jQuery updates
+ // the position of the drop zones.
+ self.draggableComponent.$scrollTarget.on('scroll.scrolling_element', function () {
+ self.$el.trigger('scroll');
+ });
+ },
+ /**
+ * 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) {
+ // 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', {container: document.body}).first();
+ if ($el.length) {
+ $el.after(this.$target);
+ this.dropped = true;
+ }
+ }
+
+ this.$editable.find('.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 = this.$editable.find('.oe_drop_clone');
+ if (prev === $clone[0]) {
+ prev = $clone[0].previousSibling;
+ } else if (next === $clone[0]) {
+ next = $clone[0].nextSibling;
+ }
+ $clone.after(this.$target);
+ var $from = $clone.parent();
+
+ this.$el.removeClass('d-none');
+ this.$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();
+ }
+
+ this.$target.trigger('content_changed');
+ $from.trigger('content_changed');
+ }
+
+ this.trigger_up('drag_and_drop_stop', {
+ $snippet: this.$target,
+ });
+ this.draggableComponent.$scrollTarget.off('scroll.scrolling_element');
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionMouseEnter: function (ev) {
+ if (!this.$target.is(':visible')) {
+ return;
+ }
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionMouseLeave: function (ev) {
+ this.trigger_up('activate_snippet', {
+ $snippet: false,
+ previewMode: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionClick: function (ev) {
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: false,
+ });
+ },
+ /**
+ * 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) {
+ var self = this;
+
+ // If multiple option names are given, we suppose it should not be
+ // propagated to parent editor
+ if (ev.data.optionNames) {
+ ev.stopPropagation();
+ _.each(ev.data.optionNames, function (name) {
+ notifyForEachMatchedOption(name);
+ });
+ }
+ // 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) {
+ if (notifyForEachMatchedOption(ev.data.optionName)) {
+ ev.stopPropagation();
+ }
+ }
+
+ function notifyForEachMatchedOption(name) {
+ var regex = new RegExp('^' + name + '\\d+$');
+ var hasOption = false;
+ for (var key in self.styles) {
+ if (key === name || regex.test(key)) {
+ self.styles[key].notify(ev.data.name, ev.data.data);
+ hasOption = true;
+ }
+ }
+ return hasOption;
+ }
+ },
+ /**
+ * Called when the 'remove' button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ this.removeSnippet();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionUpdate: async function (ev) {
+ // TODO remove me in master
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionVisibilityUpdate: function (ev) {
+ ev.data.show = this._toggleVisibilityStatus(ev.data.show);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onUserValueWidgetRequest: function (ev) {
+ ev.stopPropagation();
+ for (const key of Object.keys(this.styles)) {
+ const widget = this.styles[key].findWidget(ev.data.name);
+ if (widget) {
+ ev.data.onSuccess(widget);
+ return;
+ }
+ }
+ },
+ /**
+ * Called when the 'mouse wheel' is used when hovering over the overlay.
+ * Disable the pointer events to prevent page scrolling from stopping.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseWheel: function (ev) {
+ ev.stopPropagation();
+ this.$el.css('pointer-events', 'none');
+ clearTimeout(this.wheelTimeout);
+ this.wheelTimeout = setTimeout(() => {
+ this.$el.css('pointer-events', '');
+ }, 250);
+ },
+});
+
+/**
+ * Management of drag&drop menu and snippet related behaviors in the page.
+ */
+var SnippetsMenu = Widget.extend({
+ id: 'oe_snippets',
+ cacheSnippetTemplate: {},
+ events: {
+ 'click .oe_snippet': '_onSnippetClick',
+ 'click .o_install_btn': '_onInstallBtnClick',
+ 'click .o_we_add_snippet_btn': '_onBlocksTabClick',
+ 'click .o_we_invisible_entry': '_onInvisibleEntryClick',
+ 'click #snippet_custom .o_delete_btn': '_onDeleteBtnClick',
+ 'mousedown': '_onMouseDown',
+ 'input .o_snippet_search_filter_input': '_onSnippetSearchInput',
+ 'click .o_snippet_search_filter_reset': '_onSnippetSearchResetClick',
+ 'summernote_popover_update_call .o_we_snippet_text_tools': '_onSummernoteToolsUpdate',
+ },
+ custom_events: {
+ 'activate_insertion_zones': '_onActivateInsertionZones',
+ 'activate_snippet': '_onActivateSnippet',
+ 'call_for_each_child_snippet': '_onCallForEachChildSnippet',
+ 'clone_snippet': '_onCloneSnippet',
+ 'cover_update': '_onOverlaysCoverUpdate',
+ 'deactivate_snippet': '_onDeactivateSnippet',
+ 'drag_and_drop_stop': '_onDragAndDropStop',
+ 'get_snippet_versions': '_onGetSnippetVersions',
+ 'go_to_parent': '_onGoToParent',
+ 'remove_snippet': '_onRemoveSnippet',
+ 'snippet_edition_request': '_onSnippetEditionRequest',
+ 'snippet_editor_destroyed': '_onSnippetEditorDestroyed',
+ 'snippet_removed': '_onSnippetRemoved',
+ 'snippet_cloned': '_onSnippetCloned',
+ 'snippet_option_update': '_onSnippetOptionUpdate',
+ 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
+ 'snippet_thumbnail_url_request': '_onSnippetThumbnailURLRequest',
+ 'reload_snippet_dropzones': '_disableUndroppableSnippets',
+ 'request_save': '_onSaveRequest',
+ 'update_customize_elements': '_onUpdateCustomizeElements',
+ 'hide_overlay': '_onHideOverlay',
+ 'block_preview_overlays': '_onBlockPreviewOverlays',
+ 'unblock_preview_overlays': '_onUnblockPreviewOverlays',
+ 'user_value_widget_opening': '_onUserValueWidgetOpening',
+ 'user_value_widget_closing': '_onUserValueWidgetClosing',
+ 'reload_snippet_template': '_onReloadSnippetTemplate',
+ },
+ // enum of the SnippetsMenu's tabs.
+ tabs: {
+ BLOCKS: 'blocks',
+ OPTIONS: 'options',
+ },
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.snippets]
+ * URL of the snippets template. This URL might have been set
+ * in the global 'snippets' variable, otherwise this function
+ * assigns a default one.
+ * default: 'web_editor.snippets'
+ *
+ * @constructor
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ options = options || {};
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.options = options;
+ if (!this.options.snippets) {
+ this.options.snippets = 'web_editor.snippets';
+ }
+ this.snippetEditors = [];
+ this._enabledEditorHierarchy = [];
+
+ this._mutex = new concurrency.Mutex();
+
+ this.setSelectorEditableArea(options.$el, options.selectorEditableArea);
+
+ this._notActivableElementsSelector = [
+ '#web_editor-top-edit',
+ '.o_we_website_top_actions',
+ '#oe_snippets',
+ '#oe_manipulators',
+ '.o_technical_modal',
+ '.oe_drop_zone',
+ '.o_notification_manager',
+ '.o_we_no_overlay',
+ '.ui-autocomplete',
+ '.modal .close',
+ '.o_we_crop_widget',
+ ].join(', ');
+
+ this.loadingTimers = {};
+ this.loadingElements = {};
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ // Preload colorpalette dependencies without waiting for them. The
+ // widget have huge chances of being used by the user (clicking on any
+ // text will load it). The colorpalette itself will do the actual
+ // waiting of the loading completion.
+ ColorPaletteWidget.loadDependencies(this);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async start() {
+ var defs = [this._super.apply(this, arguments)];
+ this.ownerDocument = this.$el[0].ownerDocument;
+ this.$document = $(this.ownerDocument);
+ this.window = this.ownerDocument.defaultView;
+ this.$window = $(this.window);
+
+ this.customizePanel = document.createElement('div');
+ this.customizePanel.classList.add('o_we_customize_panel', 'd-none');
+
+ this.textEditorPanelEl = document.createElement('div');
+ this.textEditorPanelEl.classList.add('o_we_snippet_text_tools', 'd-none');
+
+ this.invisibleDOMPanelEl = document.createElement('div');
+ this.invisibleDOMPanelEl.classList.add('o_we_invisible_el_panel');
+ this.invisibleDOMPanelEl.appendChild(
+ $('<div/>', {
+ text: _t('Invisible Elements'),
+ class: 'o_panel_header',
+ })[0]
+ );
+
+ this.options.getScrollOptions = this._getScrollOptions.bind(this);
+
+ // Fetch snippet templates and compute it
+ defs.push((async () => {
+ await this._loadSnippetsTemplates();
+ await this._updateInvisibleDOM();
+ })());
+
+ // Prepare snippets editor environment
+ this.$snippetEditorArea = $('<div/>', {
+ id: 'oe_manipulators',
+ }).insertAfter(this.$el);
+
+ // Active snippet editor on click in the page
+ var lastElement;
+ const onClick = ev => {
+ var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
+ if (!srcElement || lastElement === srcElement) {
+ return;
+ }
+ lastElement = srcElement;
+ _.defer(function () {
+ lastElement = false;
+ });
+
+ var $target = $(srcElement);
+ if (!$target.closest('we-button, we-toggler, we-select, .o_we_color_preview').length) {
+ this._closeWidgets();
+ }
+ if (!$target.closest('body > *').length) {
+ return;
+ }
+ if ($target.closest(this._notActivableElementsSelector).length) {
+ return;
+ }
+ const $oeStructure = $target.closest('.oe_structure');
+ if ($oeStructure.length && !$oeStructure.children().length && this.$snippets) {
+ // If empty oe_structure, encourage using snippets in there by
+ // making them "wizz" in the panel.
+ this.$snippets.odooBounce();
+ return;
+ }
+ this._activateSnippet($target);
+ };
+
+ this.$document.on('click.snippets_menu', '*', onClick);
+ // Needed as bootstrap stop the propagation of click events for dropdowns
+ this.$document.on('mouseup.snippets_menu', '.dropdown-toggle', onClick);
+
+ core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet);
+
+ // Adapt overlay covering when the window is resized / content changes
+ var debouncedCoverUpdate = _.throttle(() => {
+ this.updateCurrentSnippetEditorOverlay();
+ }, 50);
+ this.$window.on('resize.snippets_menu', debouncedCoverUpdate);
+ this.$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
+ this.$document.on('keydown.snippets_menu', () => {
+ this.__overlayKeyWasDown = true;
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlayVisibility(false);
+ });
+ });
+ this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', _.throttle(() => {
+ if (!this.__overlayKeyWasDown) {
+ return;
+ }
+ this.__overlayKeyWasDown = false;
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlayVisibility(true);
+ editor.cover();
+ });
+ }, 250));
+
+ // Hide the active overlay when scrolling.
+ // Show it again and recompute all the overlays after the scroll.
+ this.$scrollingElement = $().getScrollingElement();
+ this._onScrollingElementScroll = _.throttle(() => {
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlayVisibility(false);
+ }
+ clearTimeout(this.scrollingTimeout);
+ this.scrollingTimeout = setTimeout(() => {
+ this._scrollingTimeout = null;
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlayVisibility(true);
+ editor.cover();
+ }
+ }, 250);
+ }, 50);
+ // We use addEventListener instead of jQuery because we need 'capture'.
+ // Setting capture to true allows to take advantage of event bubbling
+ // for events that otherwise don’t support it. (e.g. useful when
+ // scrolling a modal)
+ this.$scrollingElement[0].addEventListener('scroll', this._onScrollingElementScroll, {capture: true});
+
+ // Auto-selects text elements with a specific class and remove this
+ // on text changes
+ this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
+ $(ev.target).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
+ $(ev.target).selectContent();
+ $(ev.target).removeClass('o_default_snippet_text');
+ });
+ this.$document.on('keyup.snippets_menu', function () {
+ var range = Wysiwyg.getRange(this);
+ $(range && range.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
+ });
+
+ const $autoFocusEls = $('.o_we_snippet_autofocus');
+ this._activateSnippet($autoFocusEls.length ? $autoFocusEls.first() : false);
+
+ // Add tooltips on we-title elements whose text overflows
+ this.$el.tooltip({
+ selector: 'we-title',
+ placement: 'bottom',
+ delay: 100,
+ title: function () {
+ const el = this;
+ // On Firefox, el.scrollWidth is equal to el.clientWidth when
+ // overflow: hidden, so we need to update the style before to
+ // get the right values.
+ el.style.setProperty('overflow', 'scroll', 'important');
+ const tipContent = el.scrollWidth > el.clientWidth ? el.innerHTML : '';
+ el.style.removeProperty('overflow');
+ return tipContent;
+ },
+ });
+
+ return Promise.all(defs).then(() => {
+ this.$('[data-title]').tooltip({
+ delay: 100,
+ title: function () {
+ return this.classList.contains('active') ? false : this.dataset.title;
+ },
+ });
+
+ // 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(() => {
+ this.$window.trigger('resize');
+
+ // Hacky way to prevent to switch to text tools on editor
+ // start. Only allow switching after some delay. Switching to
+ // tools is only useful for out-of-snippet texts anyway, so
+ // snippet texts can still be enabled immediately.
+ this._mutex.exec(() => this._textToolsSwitchingEnabled = true);
+ }, 1000);
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ if (this.$window) {
+ this.$snippetEditorArea.remove();
+ this.$window.off('.snippets_menu');
+ this.$document.off('.snippets_menu');
+ this.$scrollingElement[0].removeEventListener('scroll', this._onScrollingElementScroll, {capture: true});
+ }
+ core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet);
+ delete this.cacheSnippetTemplate[this.options.snippets];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prepares the page so that it may be saved:
+ * - Asks the snippet editors to clean their associated snippet
+ * - Remove the 'contentEditable' attributes
+ */
+ cleanForSave: async function () {
+ await this._activateSnippet(false);
+ this.trigger_up('ready_to_clean_for_save');
+ await this._destroyEditors();
+
+ this.getEditableArea().find('[contentEditable]')
+ .removeAttr('contentEditable')
+ .removeProp('contentEditable');
+
+ this.getEditableArea().find('.o_we_selected_image')
+ .removeClass('o_we_selected_image');
+ },
+ /**
+ * Load snippets.
+ * @param {boolean} invalidateCache
+ */
+ loadSnippets: function (invalidateCache) {
+ if (!invalidateCache && this.cacheSnippetTemplate[this.options.snippets]) {
+ this._defLoadSnippets = this.cacheSnippetTemplate[this.options.snippets];
+ return this._defLoadSnippets;
+ }
+ this._defLoadSnippets = this._rpc({
+ model: 'ir.ui.view',
+ method: 'render_public_asset',
+ args: [this.options.snippets, {}],
+ kwargs: {
+ context: this.options.context,
+ },
+ });
+ this.cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets;
+ return this._defLoadSnippets;
+ },
+ /**
+ * Sets the instance variables $editor, $body and selectorEditableArea.
+ *
+ * @param {JQuery} $editor
+ * @param {String} selectorEditableArea
+ */
+ setSelectorEditableArea: function ($editor, selectorEditableArea) {
+ this.selectorEditableArea = selectorEditableArea;
+ this.$editor = $editor;
+ this.$body = $editor.closest('body');
+ },
+ /**
+ * Get the editable area.
+ *
+ * @returns {JQuery}
+ */
+ getEditableArea: function () {
+ return this.$editor.find(this.selectorEditableArea)
+ .add(this.$editor.filter(this.selectorEditableArea));
+ },
+ /**
+ * Updates the cover dimensions of the current snippet editor.
+ */
+ updateCurrentSnippetEditorOverlay: function () {
+ for (const snippetEditor of this.snippetEditors) {
+ if (snippetEditor.$target.closest('body').length) {
+ snippetEditor.cover();
+ continue;
+ }
+ // Destroy options whose $target are not in the DOM anymore but
+ // only do it once all options executions are done.
+ this._mutex.exec(() => snippetEditor.destroy());
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // 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 self = this;
+
+ // If a modal is open, the drop zones must be created only in this modal
+ const $openModal = self.getEditableArea().find('.modal:visible');
+ if ($openModal.length) {
+ $selectorSiblings = $openModal.find($selectorSiblings);
+ $selectorChildren = $openModal.find($selectorChildren);
+ }
+
+ // Check if the drop zone should be horizontal or vertical
+ function setDropZoneDirection($elem, $parent, $sibling) {
+ var vertical = false;
+ var style = {};
+ $sibling = $sibling || $elem;
+ var css = window.getComputedStyle($elem[0]);
+ var parentCss = window.getComputedStyle($parent[0]);
+ var float = css.float || css.cssFloat;
+ var display = parentCss.display;
+ var flex = parentCss.flexDirection;
+ if (float === 'left' || float === 'right' || (display === 'flex' && flex === 'row')) {
+ style['float'] = float;
+ if ($sibling.parent().width() !== $sibling.outerWidth(true)) {
+ vertical = true;
+ style['height'] = Math.max($sibling.outerHeight(), 30) + 'px';
+ }
+ }
+ return {
+ vertical: vertical,
+ style: style,
+ };
+ }
+
+ // If the previous sibling is a BR tag or a non-whitespace text, it
+ // should be a vertical dropzone.
+ function testPreviousSibling(node, $zone) {
+ if (!node || ((node.tagName || !node.textContent.match(/\S/)) && node.tagName !== 'BR')) {
+ return false;
+ }
+ return {
+ vertical: true,
+ style: {
+ 'float': 'none',
+ 'display': 'inline-block',
+ 'height': parseInt(self.window.getComputedStyle($zone[0]).lineHeight) + 'px',
+ },
+ };
+ }
+
+ // Firstly, add a dropzone after the clone
+ var $clone = $('.oe_drop_clone');
+ if ($clone.length) {
+ var $neighbor = $clone.prev();
+ if (!$neighbor.length) {
+ $neighbor = $clone.next();
+ }
+ var data;
+ if ($neighbor.length) {
+ data = setDropZoneDirection($neighbor, $neighbor.parent());
+ } else {
+ data = {
+ vertical: false,
+ style: {},
+ };
+ }
+ self._insertDropzone($('<we-hook/>').insertAfter($clone), data.vertical, data.style);
+ }
+
+ if ($selectorChildren) {
+ $selectorChildren.each(function () {
+ var data;
+ var $zone = $(this);
+ var $children = $zone.find('> :not(.oe_drop_zone, .oe_drop_clone)');
+
+ if (!$zone.children().last().is('.oe_drop_zone')) {
+ data = testPreviousSibling($zone[0].lastChild, $zone)
+ || setDropZoneDirection($zone, $zone, $children.last());
+ self._insertDropzone($('<we-hook/>').appendTo($zone), data.vertical, data.style);
+ }
+
+ if (!$zone.children().first().is('.oe_drop_clone')) {
+ data = testPreviousSibling($zone[0].firstChild, $zone)
+ || setDropZoneDirection($zone, $zone, $children.first());
+ self._insertDropzone($('<we-hook/>').prependTo($zone), data.vertical, data.style);
+ }
+ });
+
+ // add children near drop zone
+ $selectorSiblings = $(_.uniq(($selectorSiblings || $()).add($selectorChildren.children()).get()));
+ }
+
+ var noDropZonesSelector = '[data-invisible="1"], .o_we_no_overlay, :not(:visible)';
+ if ($selectorSiblings) {
+ $selectorSiblings.not(`.oe_drop_zone, .oe_drop_clone, ${noDropZonesSelector}`).each(function () {
+ var data;
+ var $zone = $(this);
+ var $zoneToCheck = $zone;
+
+ while ($zoneToCheck.prev(noDropZonesSelector).length) {
+ $zoneToCheck = $zoneToCheck.prev();
+ }
+ if (!$zoneToCheck.prev('.oe_drop_zone:visible, .oe_drop_clone').length) {
+ data = setDropZoneDirection($zone, $zone.parent());
+ self._insertDropzone($('<we-hook/>').insertBefore($zone), data.vertical, data.style);
+ }
+
+ $zoneToCheck = $zone;
+ while ($zoneToCheck.next(noDropZonesSelector).length) {
+ $zoneToCheck = $zoneToCheck.next();
+ }
+ if (!$zoneToCheck.next('.oe_drop_zone:visible, .oe_drop_clone').length) {
+ data = setDropZoneDirection($zone, $zone.parent());
+ self._insertDropzone($('<we-hook/>').insertAfter($zone), data.vertical, data.style);
+ }
+ });
+ }
+
+ var count;
+ var $zones;
+ do {
+ count = 0;
+ $zones = this.getEditableArea().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.getEditableArea().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 floatPrev = prev.css('float') || 'none';
+ var floatNext = next.css('float') || 'none';
+ var dispPrev = prev.css('display') || null;
+ var dispNext = next.css('display') || null;
+ if ((floatPrev === 'left' || floatPrev === 'right')
+ && (floatNext === 'left' || floatNext === 'right')) {
+ zone.remove();
+ } else if (dispPrev !== null && dispNext !== null
+ && dispPrev.indexOf('inline') >= 0 && dispNext.indexOf('inline') >= 0) {
+ zone.remove();
+ }
+ });
+ },
+ /**
+ * Adds an entry for every invisible snippet in the left panel box.
+ * The entries will contains an 'Edit' button to activate their snippet.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _updateInvisibleDOM: function () {
+ return this._execWithLoadingEffect(() => {
+ this.invisibleDOMMap = new Map();
+ const $invisibleDOMPanelEl = $(this.invisibleDOMPanelEl);
+ $invisibleDOMPanelEl.find('.o_we_invisible_entry').remove();
+ const $invisibleSnippets = globalSelector.all().find('.o_snippet_invisible').addBack('.o_snippet_invisible');
+
+ $invisibleDOMPanelEl.toggleClass('d-none', !$invisibleSnippets.length);
+
+ const proms = _.map($invisibleSnippets, async el => {
+ const editor = await this._createSnippetEditor($(el));
+ const $invisEntry = $('<div/>', {
+ class: 'o_we_invisible_entry d-flex align-items-center justify-content-between',
+ text: editor.getName(),
+ }).append($('<i/>', {class: `fa ${editor.isTargetVisible() ? 'fa-eye' : 'fa-eye-slash'} ml-2`}));
+ $invisibleDOMPanelEl.append($invisEntry);
+ this.invisibleDOMMap.set($invisEntry[0], el);
+ });
+ return Promise.all(proms);
+ }, false);
+ },
+ /**
+ * 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 (and its parent ones) need to be
+ * enabled. Only disable the current one if false is given.
+ * @param {boolean} [previewMode=false]
+ * @param {boolean} [ifInactiveOptions=false]
+ * @returns {Promise<SnippetEditor>}
+ * (might be async when an editor must be created)
+ */
+ _activateSnippet: async function ($snippet, previewMode, ifInactiveOptions) {
+ if (this._blockPreviewOverlays && previewMode) {
+ return;
+ }
+ if ($snippet && !$snippet.is(':visible')) {
+ return;
+ }
+ // Take the first parent of the provided DOM (or itself) which
+ // should have an associated snippet editor.
+ // It is important to do that before the mutex exec call to compute it
+ // before potential ancestor removal.
+ if ($snippet && $snippet.length) {
+ $snippet = globalSelector.closest($snippet);
+ }
+ const exec = previewMode
+ ? action => this._mutex.exec(action)
+ : action => this._execWithLoadingEffect(action, false);
+ return exec(() => {
+ return new Promise(resolve => {
+ if ($snippet && $snippet.length) {
+ return this._createSnippetEditor($snippet).then(resolve);
+ }
+ resolve(null);
+ }).then(async editorToEnable => {
+ if (ifInactiveOptions && this._enabledEditorHierarchy.includes(editorToEnable)) {
+ return editorToEnable;
+ }
+
+ if (!previewMode) {
+ this._enabledEditorHierarchy = [];
+ let current = editorToEnable;
+ while (current && current.$target) {
+ this._enabledEditorHierarchy.push(current);
+ current = current.getParent();
+ }
+ }
+
+ // First disable all editors...
+ for (let i = this.snippetEditors.length; i--;) {
+ const editor = this.snippetEditors[i];
+ editor.toggleOverlay(false, previewMode);
+ if (!previewMode && !this._enabledEditorHierarchy.includes(editor)) {
+ await editor.toggleOptions(false);
+ }
+ }
+ // ... if no editors are to be enabled, look if any have been
+ // enabled previously by a click
+ if (!editorToEnable) {
+ editorToEnable = this.snippetEditors.find(editor => editor.isSticky());
+ previewMode = false;
+ }
+ // ... then enable the right editor
+ if (editorToEnable) {
+ editorToEnable.toggleOverlay(true, previewMode);
+ await editorToEnable.toggleOptions(true);
+ }
+
+ return editorToEnable;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {boolean} invalidateCache
+ */
+ _loadSnippetsTemplates: async function (invalidateCache) {
+ return this._execWithLoadingEffect(async () => {
+ await this._destroyEditors();
+ const html = await this.loadSnippets(invalidateCache);
+ await this._computeSnippetTemplates(html);
+ }, false);
+ },
+ /**
+ * @private
+ * @param {jQuery|null|undefined} [$el]
+ * The DOM element whose inside editors need to be destroyed.
+ * If no element is given, all the editors are destroyed.
+ */
+ _destroyEditors: async function ($el) {
+ const proms = _.map(this.snippetEditors, async function (snippetEditor) {
+ if ($el && !$el.has(snippetEditor.$target).length) {
+ return;
+ }
+ await snippetEditor.cleanForSave();
+ snippetEditor.destroy();
+ });
+ await Promise.all(proms);
+ this.snippetEditors.splice(0);
+ },
+ /**
+ * 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 {Promise} (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 Promise.all(defs);
+ },
+ /**
+ * @private
+ */
+ _closeWidgets: function () {
+ this.snippetEditors.forEach(editor => editor.closeWidgets());
+ },
+ /**
+ * 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} selector
+ * jQuery selector that DOM elements must match to be considered as
+ * potential snippet.
+ * @param {string} exclude
+ * jQuery selector that DOM elements must *not* match to 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 (selector, exclude, target, noCheck, isChildren) {
+ var self = this;
+
+ exclude += `${exclude && ', '}.o_snippet_not_selectable`;
+
+ let filterFunc = function () {
+ return !$(this).is(exclude);
+ };
+ if (target) {
+ const oldFilter = filterFunc;
+ filterFunc = function () {
+ return oldFilter.apply(this) && $(this).find(target).length !== 0;
+ };
+ }
+
+ // Prepare the functions
+ var functions = {
+ is: function ($from) {
+ return $from.is(selector) && $from.filter(filterFunc).length !== 0;
+ },
+ };
+ if (noCheck) {
+ functions.closest = function ($from, parentNode) {
+ return $from.closest(selector, parentNode).filter(filterFunc);
+ };
+ functions.all = function ($from) {
+ return ($from ? dom.cssFind($from, selector) : $(selector)).filter(filterFunc);
+ };
+ } else {
+ functions.closest = function ($from, parentNode) {
+ var parents = self.getEditableArea().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;
+ }).filter(filterFunc);
+ };
+ functions.all = isChildren ? function ($from) {
+ return dom.cssFind($from || self.getEditableArea(), selector).filter(filterFunc);
+ } : function ($from) {
+ $from = $from || self.getEditableArea();
+ return $from.filter(selector).add(dom.cssFind($from, selector)).filter(filterFunc);
+ };
+ }
+ 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');
+
+ // TODO remove me in master: introduced in a 14.0 fix to allow users to
+ // customize their navbar with 'Boxed' website header, which they could
+ // not because of a wrong XML selector they may not update.
+ const $headerNavFix = $html.find('[data-js="HeaderNavbar"][data-selector="#wrapwrap > header > nav"]');
+ if ($headerNavFix.length) {
+ $headerNavFix[0].dataset.selector = '#wrapwrap > header nav.navbar';
+ }
+
+ 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 optionID = $style.data('js') || $style.data('option-name'); // used in tour js as selector
+ var option = {
+ 'option': optionID,
+ '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': _.extend({string: $style.attr('string')}, $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((i, el) => {
+ const $snippet = $(el);
+ const name = _.escape(el.getAttribute('name'));
+ const thumbnailSrc = _.escape(el.dataset.oeThumbnail);
+ const $sbody = $snippet.children().addClass('oe_snippet_body');
+ const isCustomSnippet = !!el.closest('#snippet_custom');
+
+ // Associate in-page snippets to their name
+ // TODO I am not sure this is useful anymore and it should at
+ // least be made more robust using data-snippet
+ let snippetClasses = $sbody.attr('class').match(/s_[^ ]+/g);
+ if (snippetClasses && snippetClasses.length) {
+ snippetClasses = '.' + snippetClasses.join('.');
+ }
+ const $els = $(snippetClasses).not('[data-name]').add($sbody);
+ $els.attr('data-name', name).data('name', name);
+
+ // Create the thumbnail
+ const $thumbnail = $(`
+ <div class="oe_snippet_thumbnail">
+ <div class="oe_snippet_thumbnail_img" style="background-image: url(${thumbnailSrc});"/>
+ <span class="oe_snippet_thumbnail_title">${name}</span>
+ </div>
+ `);
+ $snippet.prepend($thumbnail);
+
+ // Create the install button (t-install feature) if necessary
+ const moduleID = $snippet.data('moduleId');
+ if (moduleID) {
+ el.classList.add('o_snippet_install');
+ $thumbnail.append($('<button/>', {
+ class: 'btn btn-primary o_install_btn w-100',
+ type: 'button',
+ text: _t("Install"),
+ }));
+ }
+
+ // Create the delete button for custom snippets
+ if (isCustomSnippet) {
+ const btnEl = document.createElement('we-button');
+ btnEl.dataset.snippetId = $snippet.data('oeSnippetId');
+ btnEl.classList.add('o_delete_btn', 'fa', 'fa-trash', 'btn', 'o_we_hover_danger');
+ btnEl.title = _.str.sprintf(_t("Delete %s"), name);
+ $snippet.append(btnEl);
+ }
+ })
+ .not('[data-module-id]');
+
+ // Hide scroll if no snippets defined
+ if (!this.$snippets.length) {
+ this.$el.detach();
+ }
+
+ // Register the text nodes that needs to be auto-selected on click
+ this._registerDefaultTexts();
+
+ // 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.$el.append(this.customizePanel);
+ this.$el.append(this.textEditorPanelEl);
+ this.$el.append(this.invisibleDOMPanelEl);
+ this._makeSnippetDraggable(this.$snippets);
+ this._disableUndroppableSnippets();
+
+ this.$el.addClass('o_loaded');
+ $('body.editor_enable').addClass('editor_has_snippets');
+ this.trigger_up('snippets_loaded', self.$el);
+ },
+ /**
+ * 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 {Promise<SnippetEditor>}
+ */
+ _createSnippetEditor: function ($snippet) {
+ var self = this;
+ var snippetEditor = $snippet.data('snippet-editor');
+ if (snippetEditor) {
+ return snippetEditor.__isStarted;
+ }
+
+ var def;
+ var $parent = globalSelector.closest($snippet.parent());
+ if ($parent.length) {
+ def = this._createSnippetEditor($parent);
+ }
+
+ return Promise.resolve(def).then(function (parentEditor) {
+ // When reaching this position, after the Promise resolution, the
+ // snippet editor instance might have been created by another call
+ // to _createSnippetEditor... the whole logic should be improved
+ // to avoid doing this here.
+ snippetEditor = $snippet.data('snippet-editor');
+ if (snippetEditor) {
+ return snippetEditor.__isStarted;
+ }
+
+ let editableArea = self.getEditableArea();
+ snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions, $snippet.closest('[data-oe-type="html"], .oe_structure').add(editableArea), self.options);
+ 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 $snippetBody = $snippet.find('.oe_snippet_body');
+
+ var check = false;
+ _.each(self.templateOptions, function (option, k) {
+ if (check || !($snippetBody.is(option.base_selector) && !$snippetBody.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);
+ $snippet.attr('title', check ? '' : _t("No location to drop in"));
+ const $icon = $snippet.find('.o_snippet_undroppable').remove();
+ if (check) {
+ $icon.remove();
+ } else if (!$icon.length) {
+ const imgEl = document.createElement('img');
+ imgEl.classList.add('o_snippet_undroppable');
+ imgEl.src = '/web_editor/static/src/img/snippet_disabled.svg';
+ $snippet.append(imgEl);
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {string} [search]
+ */
+ _filterSnippets(search) {
+ const searchInputEl = this.el.querySelector('.o_snippet_search_filter_input');
+ const searchInputReset = this.el.querySelector('.o_snippet_search_filter_reset');
+ if (search !== undefined) {
+ searchInputEl.value = search;
+ } else {
+ search = searchInputEl.value;
+ }
+ search = search.toLowerCase();
+ searchInputReset.classList.toggle('d-none', !search);
+ const strMatches = str => !search || str.toLowerCase().includes(search);
+ for (const panelEl of this.el.querySelectorAll('.o_panel')) {
+ let hasVisibleSnippet = false;
+ const panelTitle = panelEl.querySelector('.o_panel_header').textContent;
+ const isPanelTitleMatch = strMatches(panelTitle);
+ for (const snippetEl of panelEl.querySelectorAll('.oe_snippet')) {
+ const matches = (isPanelTitleMatch
+ || strMatches(snippetEl.getAttribute('name'))
+ || strMatches(snippetEl.dataset.oeKeywords || ''));
+ if (matches) {
+ hasVisibleSnippet = true;
+ }
+ snippetEl.classList.toggle('d-none', !matches);
+ }
+ panelEl.classList.toggle('d-none', !hasVisibleSnippet);
+ }
+ },
+ /**
+ * @private
+ * @param {Object} [options={}]
+ * @returns {Object}
+ */
+ _getScrollOptions(options = {}) {
+ return Object.assign({}, options, {
+ scrollBoundaries: Object.assign({
+ right: false,
+ }, options.scrollBoundaries),
+ jQueryDraggableOptions: Object.assign({
+ appendTo: this.$body,
+ cursor: 'move',
+ greedy: true,
+ scroll: false,
+ }, options.jQueryDraggableOptions),
+ disableHorizontalScroll: true,
+ });
+ },
+ /**
+ * Creates a dropzone element and inserts it by replacing the given jQuery
+ * location. This allows to add data on the dropzone depending on the hook
+ * environment.
+ *
+ * @private
+ * @param {jQuery} $hook
+ * @param {boolean} [vertical=false]
+ * @param {Object} [style]
+ */
+ _insertDropzone: function ($hook, vertical, style) {
+ var $dropzone = $('<div/>', {
+ 'class': 'oe_drop_zone oe_insert' + (vertical ? ' oe_vertical' : ''),
+ });
+ if (style) {
+ $dropzone.css(style);
+ }
+ $hook.replaceWith($dropzone);
+ return $dropzone;
+ },
+ /**
+ * Make given snippets be draggable/droppable thanks to their thumbnail.
+ *
+ * @private
+ * @param {jQuery} $snippets
+ */
+ _makeSnippetDraggable: function ($snippets) {
+ var self = this;
+ var $toInsert, dropped, $snippet;
+
+ let dragAndDropResolve;
+ const $scrollingElement = $().getScrollingElement();
+
+ const smoothScrollOptions = this._getScrollOptions({
+ jQueryDraggableOptions: {
+ handle: '.oe_snippet_thumbnail:not(.o_we_already_dragging)',
+ helper: function () {
+ const dragSnip = this.cloneNode(true);
+ dragSnip.querySelectorAll('.o_delete_btn').forEach(
+ el => el.remove()
+ );
+ return dragSnip;
+ },
+ start: function () {
+ self.$el.find('.oe_snippet_thumbnail').addClass('o_we_already_dragging');
+
+ dropped = false;
+ $snippet = $(this);
+ var $baseBody = $snippet.find('.oe_snippet_body');
+ var $selectorSiblings = $();
+ var $selectorChildren = $();
+ var temp = self.templateOptions;
+ for (var k in temp) {
+ if ($baseBody.is(temp[k].base_selector) && !$baseBody.is(temp[k].base_exclude)) {
+ if (temp[k]['drop-near']) {
+ $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all());
+ }
+ if (temp[k]['drop-in']) {
+ $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all());
+ }
+ }
+ }
+
+ $toInsert = $baseBody.clone();
+ // Color-customize dynamic SVGs in dropped snippets with current theme colors.
+ [...$toInsert.find('img[src^="/web_editor/shape/"]')].forEach(dynamicSvg => {
+ const colorCustomizedURL = new URL(dynamicSvg.getAttribute('src'), window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ dynamicSvg.src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ });
+
+ 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._activateInsertionZones($selectorSiblings, $selectorChildren);
+
+ self.getEditableArea().find('.oe_drop_zone').droppable({
+ over: function () {
+ if (dropped) {
+ $toInsert.detach();
+ $toInsert.addClass('oe_snippet_body');
+ $('.oe_drop_zone').removeClass('invisible');
+ }
+ dropped = true;
+ $(this).first().after($toInsert).addClass('invisible');
+ $toInsert.removeClass('oe_snippet_body');
+ },
+ out: function () {
+ var prev = $toInsert.prev();
+ if (this === prev[0]) {
+ dropped = false;
+ $toInsert.detach();
+ $(this).removeClass('invisible');
+ $toInsert.addClass('oe_snippet_body');
+ }
+ },
+ });
+
+ // If a modal is open, the scroll target must be that modal
+ const $openModal = self.getEditableArea().find('.modal:visible');
+ self.draggableComponent.$scrollTarget = $openModal.length ? $openModal : $scrollingElement;
+
+ // Trigger a scroll on the draggable element so that jQuery updates
+ // the position of the drop zones.
+ self.draggableComponent.$scrollTarget.on('scroll.scrolling_element', function () {
+ self.$el.trigger('scroll');
+ });
+
+ const prom = new Promise(resolve => dragAndDropResolve = () => resolve());
+ self._mutex.exec(() => prom);
+ },
+ stop: async function (ev, ui) {
+ $toInsert.removeClass('oe_snippet_body');
+ self.draggableComponent.$scrollTarget.off('scroll.scrolling_element');
+
+ if (!dropped && ui.position.top > 3 && ui.position.left + ui.helper.outerHeight() < self.el.getBoundingClientRect().left) {
+ var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone', {container: document.body}).first();
+ if ($el.length) {
+ $el.after($toInsert);
+ dropped = true;
+ }
+ }
+
+ self.getEditableArea().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);
+ }
+
+ var $target = $toInsert;
+ await self._scrollToSnippet($target);
+
+ _.defer(async function () {
+ self.trigger_up('snippet_dropped', {$target: $target});
+ self._disableUndroppableSnippets();
+
+ dragAndDropResolve();
+
+ await self._callForEachChildSnippet($target, function (editor, $snippet) {
+ return editor.buildSnippet();
+ });
+ $target.trigger('content_changed');
+ await self._updateInvisibleDOM();
+
+ self.$el.find('.oe_snippet_thumbnail').removeClass('o_we_already_dragging');
+ });
+ } else {
+ $toInsert.remove();
+ dragAndDropResolve();
+ self.$el.find('.oe_snippet_thumbnail').removeClass('o_we_already_dragging');
+ }
+ },
+ },
+ });
+ this.draggableComponent = new SmoothScrollOnDrag(this, $snippets, $scrollingElement, smoothScrollOptions);
+ },
+ /**
+ * 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');
+ },
+ /**
+ * Changes the content of the left panel and selects a tab.
+ *
+ * @private
+ * @param {htmlString | Element | Text | Array | jQuery} [content]
+ * the new content of the customizePanel
+ * @param {this.tabs.VALUE} [tab='blocks'] - the tab to select
+ */
+ _updateLeftPanelContent: function ({content, tab}) {
+ clearTimeout(this._textToolsSwitchingTimeout);
+ this._closeWidgets();
+
+ tab = tab || this.tabs.BLOCKS;
+
+ if (content) {
+ while (this.customizePanel.firstChild) {
+ this.customizePanel.removeChild(this.customizePanel.firstChild);
+ }
+ $(this.customizePanel).append(content);
+ }
+
+ this.$('.o_snippet_search_filter').toggleClass('d-none', tab !== this.tabs.BLOCKS);
+ this.$('#o_scroll').toggleClass('d-none', tab !== this.tabs.BLOCKS);
+ this.customizePanel.classList.toggle('d-none', tab === this.tabs.BLOCKS);
+ this.textEditorPanelEl.classList.toggle('d-none', tab !== this.tabs.OPTIONS);
+
+ this.$('.o_we_add_snippet_btn').toggleClass('active', tab === this.tabs.BLOCKS);
+ this.$('.o_we_customize_snippet_btn').toggleClass('active', tab === this.tabs.OPTIONS)
+ .prop('disabled', tab !== this.tabs.OPTIONS);
+
+ },
+ /**
+ * Scrolls to given snippet.
+ *
+ * @private
+ * @param {jQuery} $el - snippet to scroll to
+ * @return {Promise}
+ */
+ async _scrollToSnippet($el) {
+ return dom.scrollTo($el[0], {extraOffset: 50});
+ },
+ /**
+ * @private
+ * @returns {HTMLElement}
+ */
+ _createLoadingElement() {
+ const loaderContainer = document.createElement('div');
+ const loader = document.createElement('i');
+ const loaderContainerClassList = [
+ 'o_we_ui_loading',
+ 'd-flex',
+ 'justify-content-center',
+ 'align-items-center',
+ ];
+ const loaderClassList = [
+ 'fa',
+ 'fa-circle-o-notch',
+ 'fa-spin',
+ 'fa-4x',
+ ];
+ loaderContainer.classList.add(...loaderContainerClassList);
+ loader.classList.add(...loaderClassList);
+ loaderContainer.appendChild(loader);
+ return loaderContainer;
+ },
+ /**
+ * Adds the action to the mutex queue and sets a loading effect over the
+ * editor to appear if the action takes too much time.
+ * As soon as the mutex is unlocked, the loading effect will be removed.
+ *
+ * @private
+ * @param {function} action
+ * @param {boolean} [contentLoading=true]
+ * @param {number} [delay=500]
+ * @returns {Promise}
+ */
+ async _execWithLoadingEffect(action, contentLoading = true, delay = 500) {
+ const mutexExecResult = this._mutex.exec(action);
+ if (!this.loadingTimers[contentLoading]) {
+ const addLoader = () => {
+ this.loadingElements[contentLoading] = this._createLoadingElement();
+ if (contentLoading) {
+ this.$snippetEditorArea.append(this.loadingElements[contentLoading]);
+ } else {
+ this.el.appendChild(this.loadingElements[contentLoading]);
+ }
+ };
+ if (delay) {
+ this.loadingTimers[contentLoading] = setTimeout(addLoader, delay);
+ } else {
+ addLoader();
+ }
+ this._mutex.getUnlockedDef().then(() => {
+ // Note: we remove the loading element at the end of the
+ // execution queue *even if subsequent actions are content
+ // related or not*. This is a limitation of the loading feature,
+ // the goal is still to limit the number of elements in that
+ // queue anyway.
+ if (delay) {
+ clearTimeout(this.loadingTimers[contentLoading]);
+ this.loadingTimers[contentLoading] = undefined;
+ }
+
+ if (this.loadingElements[contentLoading]) {
+ this.loadingElements[contentLoading].remove();
+ this.loadingElements[contentLoading] = null;
+ }
+ });
+ }
+ return mutexExecResult;
+ },
+
+ //--------------------------------------------------------------------------
+ // 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 deactivate the current snippet
+ * overlay.
+ *
+ * @private
+ */
+ _onActivateSnippet: function (ev) {
+ this._activateSnippet(ev.data.$snippet, ev.data.previewMode, ev.data.ifInactiveOptions);
+ },
+ /**
+ * 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 the overlay dimensions/positions should be recomputed.
+ *
+ * @private
+ */
+ _onOverlaysCoverUpdate: function () {
+ this.snippetEditors.forEach(editor => {
+ editor.cover();
+ });
+ },
+ /**
+ * Called when a child editor asks to clone a snippet, allows to correctly
+ * call the _onClone methods if the element's editor has one.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCloneSnippet: async function (ev) {
+ ev.stopPropagation();
+ const editor = await this._createSnippetEditor(ev.data.$snippet);
+ await editor.clone();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ },
+ /**
+ * 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: async function (ev) {
+ const $modal = ev.data.$snippet.closest('.modal');
+ // If the snippet is in a modal, destroy editors only in that modal.
+ // This to prevent the modal from closing because of the cleanForSave
+ // on each editors.
+ await this._destroyEditors($modal.length ? $modal : null);
+ await 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
+ */
+ _onHideOverlay: function () {
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlay(false);
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInstallBtnClick: function (ev) {
+ var self = this;
+ var $snippet = $(ev.currentTarget).closest('[data-module-id]');
+ var moduleID = $snippet.data('moduleId');
+ var name = $snippet.attr('name');
+ new Dialog(this, {
+ title: _.str.sprintf(_t("Install %s"), name),
+ size: 'medium',
+ $content: $('<div/>', {text: _.str.sprintf(_t("Do you want to install the %s App?"), name)}).append(
+ $('<a/>', {
+ target: '_blank',
+ href: '/web#id=' + moduleID + '&view_type=form&model=ir.module.module&action=base.open_module_tree',
+ text: _t("More info about this app."),
+ class: 'ml4',
+ })
+ ),
+ buttons: [{
+ text: _t("Save and Install"),
+ classes: 'btn-primary',
+ click: function () {
+ this.$footer.find('.btn').toggleClass('o_hidden');
+ this._rpc({
+ model: 'ir.module.module',
+ method: 'button_immediate_install',
+ args: [[moduleID]],
+ }).then(() => {
+ self.trigger_up('request_save', {
+ reloadEditor: true,
+ _toMutex: true,
+ });
+ }).guardedCatch(reason => {
+ reason.event.preventDefault();
+ this.close();
+ self.displayNotification({
+ message: _.str.sprintf(_t("Could not install module <strong>%s</strong>"), name),
+ type: 'danger',
+ sticky: true,
+ });
+ });
+ },
+ }, {
+ text: _t("Install in progress"),
+ icon: 'fa-spin fa-spinner fa-pulse mr8',
+ classes: 'btn-primary disabled o_hidden',
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ }],
+ }).open();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInvisibleEntryClick: async function (ev) {
+ ev.preventDefault();
+ const $snippet = $(this.invisibleDOMMap.get(ev.currentTarget));
+ const isVisible = await this._execWithLoadingEffect(async () => {
+ const editor = await this._createSnippetEditor($snippet);
+ return editor.toggleTargetVisibility();
+ }, true);
+ $(ev.currentTarget).find('.fa')
+ .toggleClass('fa-eye', isVisible)
+ .toggleClass('fa-eye-slash', !isVisible);
+ return this._activateSnippet(isVisible ? $snippet : false);
+ },
+ /**
+ * @private
+ */
+ _onBlocksTabClick: function (ev) {
+ this._activateSnippet(false).then(() => {
+ this._updateLeftPanelContent({
+ content: [],
+ tab: this.tabs.BLOCKS,
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _onDeleteBtnClick: function (ev) {
+ const $snippet = $(ev.target).closest('.oe_snippet');
+ const snippetId = parseInt(ev.currentTarget.dataset.snippetId);
+ ev.stopPropagation();
+ new Dialog(this, {
+ size: 'medium',
+ title: _t('Confirmation'),
+ $content: $('<div><p>' + _.str.sprintf(_t("Are you sure you want to delete the snippet: %s ?"), $snippet.attr('name')) + '</p></div>'),
+ buttons: [{
+ text: _t("Yes"),
+ close: true,
+ classes: 'btn-primary',
+ click: async () => {
+ await this._rpc({
+ model: 'ir.ui.view',
+ method: 'delete_snippet',
+ kwargs: {
+ 'view_id': snippetId,
+ 'template_key': this.options.snippets,
+ },
+ });
+ await this._loadSnippetsTemplates(true);
+ },
+ }, {
+ text: _t("No"),
+ close: true,
+ }],
+ }).open();
+ },
+ /**
+ * Prevents pointer-events to change the focus when a pointer slide from
+ * left-panel to the editable area.
+ *
+ * @private
+ */
+ _onMouseDown: function () {
+ const $blockedArea = $('#wrapwrap'); // TODO should get that element another way
+ $blockedArea.addClass('o_we_no_pointer_events');
+ const reenable = () => $blockedArea.removeClass('o_we_no_pointer_events');
+ // Use a setTimeout fallback to avoid locking the editor if the mouseup
+ // is fired over an element which stops propagation for example.
+ const enableTimeoutID = setTimeout(() => reenable(), 5000);
+ $(document).one('mouseup', () => {
+ clearTimeout(enableTimeoutID);
+ reenable();
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGetSnippetVersions: function (ev) {
+ const snippet = this.el.querySelector(`.oe_snippet > [data-snippet="${ev.data.snippetName}"]`);
+ ev.data.onSuccess(snippet && {
+ vcss: snippet.dataset.vcss,
+ vjs: snippet.dataset.vjs,
+ vxml: snippet.dataset.vxml,
+ });
+ },
+ /**
+ * UNUSED: used to be called when saving a custom snippet. We now save and
+ * reload the page when saving a custom snippet so that all the DOM cleanup
+ * mechanisms are run before saving. Kept for compatibility.
+ *
+ * TODO: remove in master / find a way to clean the DOM without save+reload
+ *
+ * @private
+ */
+ _onReloadSnippetTemplate: async function (ev) {
+ await this._activateSnippet(false);
+ await this._loadSnippetsTemplates(true);
+ },
+ /**
+ * @private
+ */
+ _onBlockPreviewOverlays: function (ev) {
+ this._blockPreviewOverlays = true;
+ },
+ /**
+ * @private
+ */
+ _onUnblockPreviewOverlays: function (ev) {
+ this._blockPreviewOverlays = false;
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRemoveSnippet: async function (ev) {
+ ev.stopPropagation();
+ const editor = await this._createSnippetEditor(ev.data.$snippet);
+ await editor.removeSnippet();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ },
+ /**
+ * Saving will destroy all editors since they need to clean their DOM.
+ * This has thus to be done when they are all finished doing their work.
+ *
+ * @private
+ */
+ _onSaveRequest: function (ev) {
+ const data = ev.data;
+ if (ev.target === this && !data._toMutex) {
+ return;
+ }
+ delete data._toMutex;
+ ev.stopPropagation();
+ this._execWithLoadingEffect(() => {
+ if (data.reloadEditor) {
+ data.reload = false;
+ const oldOnSuccess = data.onSuccess;
+ data.onSuccess = async function () {
+ if (oldOnSuccess) {
+ await oldOnSuccess.call(this, ...arguments);
+ }
+ window.location.href = window.location.origin + window.location.pathname + '?enable_editor=1';
+ };
+ }
+ this.trigger_up('request_save', data);
+ }, true);
+ },
+ /**
+ * @private
+ */
+ _onSnippetClick() {
+ const $els = this.getEditableArea().find('.oe_structure.oe_empty').addBack('.oe_structure.oe_empty');
+ for (const el of $els) {
+ if (!el.children.length) {
+ $(el).odooBounce('o_we_snippet_area_animation');
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {function} ev.data.exec
+ */
+ _onSnippetEditionRequest: function (ev) {
+ this._execWithLoadingEffect(ev.data.exec, true);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetEditorDestroyed(ev) {
+ ev.stopPropagation();
+ const index = this.snippetEditors.indexOf(ev.target);
+ this.snippetEditors.splice(index, 1);
+ },
+ /**
+ * @private
+ */
+ _onSnippetCloned: function (ev) {
+ this._updateInvisibleDOM();
+ },
+ /**
+ * Called when a snippet is removed -> checks if there is draggable snippets
+ * to enable/disable as the DOM changed.
+ *
+ * @private
+ */
+ _onSnippetRemoved: function () {
+ this._disableUndroppableSnippets();
+ this._updateInvisibleDOM();
+ },
+ /**
+ * When the editor panel receives a notification indicating that an option
+ * was used, the panel is in charge of asking for an UI update of the whole
+ * panel. Logically, the options are displayed so that an option above
+ * may influence the status and visibility of an option which is below;
+ * e.g.:
+ * - the user sets a badge type to 'info'
+ * -> the badge background option (below) is shown as blue
+ * - the user adds a shadow
+ * -> more options are shown afterwards to control it (not above)
+ *
+ * Technically we however update the whole editor panel (parent and child
+ * options) wherever the updates comes from. The only important thing is
+ * to first update the options UI then their visibility as their visibility
+ * may depend on their UI status.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionUpdate(ev) {
+ ev.stopPropagation();
+ (async () => {
+ const editors = this._enabledEditorHierarchy;
+ await Promise.all(editors.map(editor => editor.updateOptionsUI()));
+ await Promise.all(editors.map(editor => editor.updateOptionsUIVisibility()));
+ ev.data.onSuccess();
+ })();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionVisibilityUpdate: async function (ev) {
+ if (!ev.data.show) {
+ await this._activateSnippet(false);
+ }
+ await this._updateInvisibleDOM(); // Re-render to update status
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetThumbnailURLRequest(ev) {
+ const $snippet = this.$snippets.has(`[data-snippet="${ev.data.key}"]`);
+ ev.data.onSuccess($snippet.length ? $snippet[0].dataset.oeThumbnail : '');
+ },
+ /**
+ * @private
+ */
+ _onSummernoteToolsUpdate(ev) {
+ if (!this._textToolsSwitchingEnabled) {
+ return;
+ }
+ const range = $.summernote.core.range.create();
+ if (!range) {
+ return;
+ }
+ if (range.sc === range.ec && range.sc.nodeType === Node.ELEMENT_NODE
+ && range.sc.classList.contains('oe_structure')
+ && range.sc.children.length === 0) {
+ // Do not switch to text tools if the cursor is in an empty
+ // oe_structure (to encourage using snippets there and actually
+ // avoid breaking tours which suppose the snippet list is visible).
+ return;
+ }
+ this.textEditorPanelEl.classList.add('d-block');
+ const hasVisibleButtons = !!$(this.textEditorPanelEl).find('.btn:visible').length;
+ this.textEditorPanelEl.classList.remove('d-block');
+ if (!hasVisibleButtons) {
+ // Ugly way to detect that summernote was updated but there is no
+ // visible text tools.
+ return;
+ }
+ // Only switch tab without changing content (_updateLeftPanelContent
+ // make text tools visible only on that specific tab). Also do it with
+ // a slight delay to avoid flickering doing it twice.
+ clearTimeout(this._textToolsSwitchingTimeout);
+ if (!this.$('#o_scroll').hasClass('d-none')) {
+ this._textToolsSwitchingTimeout = setTimeout(() => {
+ this._updateLeftPanelContent({tab: this.tabs.OPTIONS});
+ }, 250);
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUpdateCustomizeElements: function (ev) {
+ this._updateLeftPanelContent({
+ content: ev.data.customize$Elements,
+ tab: ev.data.customize$Elements.length ? this.tabs.OPTIONS : this.tabs.BLOCKS,
+ });
+ },
+ /**
+ * Called when an user value widget is being opened -> close all the other
+ * user value widgets of all editors + add backdrop.
+ */
+ _onUserValueWidgetOpening: function () {
+ this._closeWidgets();
+ this.el.classList.add('o_we_backdrop');
+ },
+ /**
+ * Called when an user value widget is being closed -> rely on the fact only
+ * one widget can be opened at a time: remove the backdrop.
+ */
+ _onUserValueWidgetClosing: function () {
+ this.el.classList.remove('o_we_backdrop');
+ },
+ /**
+ * Called when search input value changed -> adapts the snippets grid.
+ *
+ * @private
+ */
+ _onSnippetSearchInput: function () {
+ this._filterSnippets();
+ },
+ /**
+ * Called on snippet search filter reset -> clear input field search.
+ *
+ * @private
+ */
+ _onSnippetSearchResetClick: function () {
+ this._filterSnippets('');
+ },
+});
+
+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 00000000..f89d5791
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -0,0 +1,4908 @@
+odoo.define('web_editor.snippets.options', function (require) {
+'use strict';
+
+var core = require('web.core');
+const {ColorpickerWidget} = require('web.Colorpicker');
+const Dialog = require('web.Dialog');
+const rpc = require('web.rpc');
+const time = require('web.time');
+var Widget = require('web.Widget');
+var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
+const weUtils = require('web_editor.utils');
+const {
+ normalizeColor,
+ getBgImageURL,
+} = weUtils;
+var weWidgets = require('wysiwyg.widgets');
+const {
+ loadImage,
+ loadImageInfo,
+ applyModifications,
+ removeOnImageChangeAttrs,
+} = require('web_editor.image_processing');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * @param {HTMLElement} el
+ * @param {string} [title]
+ * @param {Object} [options]
+ * @param {string[]} [options.classes]
+ * @param {string} [options.tooltip]
+ * @param {string} [options.placeholder]
+ * @param {Object} [options.dataAttributes]
+ * @returns {HTMLElement} - the original 'el' argument
+ */
+function _addTitleAndAllowedAttributes(el, title, options) {
+ let tooltipEl = el;
+ if (title) {
+ const titleEl = _buildTitleElement(title);
+ tooltipEl = titleEl;
+ el.appendChild(titleEl);
+ }
+
+ if (options && options.classes) {
+ el.classList.add(...options.classes);
+ }
+ if (options && options.tooltip) {
+ tooltipEl.title = options.tooltip;
+ }
+ if (options && options.placeholder) {
+ el.setAttribute('placeholder', options.placeholder);
+ }
+ if (options && options.dataAttributes) {
+ for (const key in options.dataAttributes) {
+ el.dataset[key] = options.dataAttributes[key];
+ }
+ }
+
+ return el;
+}
+/**
+ * @param {string} tagName
+ * @param {string} title - @see _addTitleAndAllowedAttributes
+ * @param {Object} options - @see _addTitleAndAllowedAttributes
+ * @returns {HTMLElement}
+ */
+function _buildElement(tagName, title, options) {
+ const el = document.createElement(tagName);
+ return _addTitleAndAllowedAttributes(el, title, options);
+}
+/**
+ * @param {string} title
+ * @returns {HTMLElement}
+ */
+function _buildTitleElement(title) {
+ const titleEl = document.createElement('we-title');
+ titleEl.textContent = title;
+ return titleEl;
+}
+/**
+ * @param {string} src
+ * @returns {HTMLElement}
+ */
+const _buildImgElementCache = {};
+async function _buildImgElement(src) {
+ if (!(src in _buildImgElementCache)) {
+ _buildImgElementCache[src] = (async () => {
+ if (src.split('.').pop() === 'svg') {
+ const response = await window.fetch(src);
+ const text = await response.text();
+ const parser = new window.DOMParser();
+ const xmlDoc = parser.parseFromString(text, 'text/xml');
+ return xmlDoc.getElementsByTagName('svg')[0];
+ } else {
+ const imgEl = document.createElement('img');
+ imgEl.src = src;
+ return imgEl;
+ }
+ })();
+ }
+ const node = await _buildImgElementCache[src];
+ return node.cloneNode(true);
+}
+/**
+ * Build the correct DOM for a we-row element.
+ *
+ * @param {string} [title] - @see _buildElement
+ * @param {Object} [options] - @see _buildElement
+ * @param {HTMLElement[]} [options.childNodes]
+ * @returns {HTMLElement}
+ */
+function _buildRowElement(title, options) {
+ const groupEl = _buildElement('we-row', title, options);
+
+ const rowEl = document.createElement('div');
+ groupEl.appendChild(rowEl);
+
+ if (options && options.childNodes) {
+ options.childNodes.forEach(node => rowEl.appendChild(node));
+ }
+
+ return groupEl;
+}
+/**
+ * Build the correct DOM for a we-collapse element.
+ *
+ * @param {string} [title] - @see _buildElement
+ * @param {Object} [options] - @see _buildElement
+ * @param {HTMLElement[]} [options.childNodes]
+ * @returns {HTMLElement}
+ */
+function _buildCollapseElement(title, options) {
+ const groupEl = _buildElement('we-collapse', title, options);
+ const titleEl = groupEl.querySelector('we-title');
+
+ const children = options && options.childNodes || [];
+ if (titleEl) {
+ titleEl.remove();
+ children.unshift(titleEl);
+ }
+ let i = 0;
+ for (i = 0; i < children.length; i++) {
+ groupEl.appendChild(children[i]);
+ if (children[i].nodeType === Node.ELEMENT_NODE) {
+ break;
+ }
+ }
+
+ const togglerEl = document.createElement('we-toggler');
+ togglerEl.classList.add('o_we_collapse_toggler');
+ groupEl.appendChild(togglerEl);
+
+ const containerEl = document.createElement('div');
+ children.slice(i + 1).forEach(node => containerEl.appendChild(node));
+ groupEl.appendChild(containerEl);
+
+ return groupEl;
+}
+/**
+ * Creates a proxy for an object where one property is replaced by a different
+ * value. This value is captured in the closure and can be read and written to.
+ *
+ * @param {Object} obj - the object for which to create a proxy
+ * @param {string} propertyName - the name/key of the property to replace
+ * @param {*} value - the initial value to give to the property's copy
+ * @returns {Proxy} a proxy of the object with the property replaced
+ */
+function createPropertyProxy(obj, propertyName, value) {
+ return new Proxy(obj, {
+ get: function (obj, prop) {
+ if (prop === propertyName) {
+ return value;
+ }
+ return obj[prop];
+ },
+ set: function (obj, prop, val) {
+ if (prop === propertyName) {
+ return (value = val);
+ }
+ return Reflect.set(...arguments);
+ },
+ });
+}
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+const NULL_ID = '__NULL__';
+
+/**
+ * Base class for components to be used in snippet options widgets to retrieve
+ * user values.
+ */
+const UserValueWidget = Widget.extend({
+ className: 'o_we_user_value_widget',
+ custom_events: {
+ 'user_value_update': '_onUserValueNotification',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, title, options, $target) {
+ this._super(...arguments);
+ this.title = title;
+ this.options = options;
+ this._userValueWidgets = [];
+ this._value = '';
+ this.$target = $target;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ if (this.options.dataAttributes.img) {
+ this.imgEl = await _buildImgElement(this.options.dataAttributes.img);
+ }
+ },
+ /**
+ * @override
+ */
+ _makeDescriptive: function () {
+ const $el = this._super(...arguments);
+ const el = $el[0];
+ _addTitleAndAllowedAttributes(el, this.title, this.options);
+ this.containerEl = document.createElement('div');
+
+ if (this.imgEl) {
+ this.containerEl.appendChild(this.imgEl);
+ }
+
+ el.appendChild(this.containerEl);
+ return $el;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (this.el.classList.contains('o_we_img_animate')) {
+ const buildImgExtensionSwitcher = (from, to) => {
+ const regex = new RegExp(`${from}$`, 'i');
+ return ev => {
+ const img = ev.currentTarget.getElementsByTagName("img")[0];
+ img.src = img.src.replace(regex, to);
+ };
+ };
+ this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif'));
+ this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png'));
+ }
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ // Check if $el exists in case the widget is destroyed before it has
+ // been fully initialized.
+ // TODO there is probably better to do. This case was found only in
+ // tours, where the editor is left before the widget icon is loaded.
+ if (this.$el) {
+ this.$el.off('.img_animate');
+ }
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Closes the widget (only meaningful for widgets that can be closed).
+ */
+ close: function () {
+ if (!this.el) {
+ // In case the method is called while the widget is not fully
+ // initialized yet. No need to prevent that case: asking a non
+ // initialized widget to close itself should just not be a problem
+ // and just be ignored.
+ return;
+ }
+ this.trigger_up('user_value_widget_closing');
+ this.el.classList.remove('o_we_widget_opened');
+ this._userValueWidgets.forEach(widget => widget.close());
+ },
+ /**
+ * Simulates the correct event on the element to make it active.
+ */
+ enable() {
+ this.$el.click();
+ },
+ /**
+ * @param {string} name
+ * @returns {UserValueWidget|null}
+ */
+ findWidget: function (name) {
+ for (const widget of this._userValueWidgets) {
+ if (widget.getName() === name) {
+ return widget;
+ }
+ const depWidget = widget.findWidget(name);
+ if (depWidget) {
+ return depWidget;
+ }
+ }
+ return null;
+ },
+ /**
+ * Returns the value that the widget would hold if it was active, by default
+ * the internal value it holds.
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getActiveValue: function (methodName) {
+ return this._value;
+ },
+ /**
+ * Returns the default value the widget holds when inactive, by default the
+ * first "possible value".
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getDefaultValue: function (methodName) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ return possibleValues && possibleValues[0] || '';
+ },
+ /**
+ * @returns {string[]}
+ */
+ getDependencies: function () {
+ return this._dependencies;
+ },
+ /**
+ * Returns the names of the option methods associated to the widget. Those
+ * are loaded with @see loadMethodsData.
+ *
+ * @returns {string[]}
+ */
+ getMethodsNames: function () {
+ return this._methodsNames;
+ },
+ /**
+ * Returns the option parameters associated to the widget (for a given
+ * method name or not). Most are loaded with @see loadMethodsData.
+ *
+ * @param {string} [methodName]
+ * @returns {Object}
+ */
+ getMethodsParams: function (methodName) {
+ const params = _.extend({}, this._methodsParams);
+ if (methodName) {
+ params.possibleValues = params.optionsPossibleValues[methodName] || [];
+ params.activeValue = this.getActiveValue(methodName);
+ params.defaultValue = this.getDefaultValue(methodName);
+ }
+ return params;
+ },
+ /**
+ * @returns {string} empty string if no name is used by the widget
+ */
+ getName: function () {
+ return this._methodsParams.name || '';
+ },
+ /**
+ * Returns the user value that the widget currently holds. The value is a
+ * string, this is the value that will be received in the option methods
+ * of SnippetOptionWidget instances.
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getValue: function (methodName) {
+ const isActive = this.isActive();
+ if (!methodName || !this._methodsNames.includes(methodName)) {
+ return isActive ? 'true' : '';
+ }
+ if (isActive) {
+ return this.getActiveValue(methodName);
+ }
+ return this.getDefaultValue(methodName);
+ },
+ /**
+ * Returns whether or not the widget is active (holds a value).
+ *
+ * @returns {boolean}
+ */
+ isActive: function () {
+ return this._value && this._value !== NULL_ID;
+ },
+ /**
+ * Indicates if the widget can contain sub user value widgets or not.
+ *
+ * @returns {boolean}
+ */
+ isContainer: function () {
+ return false;
+ },
+ /**
+ * Indicates if the widget is being previewed or not: the user is
+ * manipulating it. Base case: if an internal <input/> element is focused.
+ *
+ * @returns {boolean}
+ */
+ isPreviewed: function () {
+ const focusEl = document.activeElement;
+ if (focusEl && focusEl.tagName === 'INPUT'
+ && (this.el === focusEl || this.el.contains(focusEl))) {
+ return true;
+ }
+ return this.el.classList.contains('o_we_preview');
+ },
+ /**
+ * Loads option method names and option method parameters.
+ *
+ * @param {string[]} validMethodNames
+ * @param {Object} extraParams
+ */
+ loadMethodsData: function (validMethodNames, extraParams) {
+ this._methodsNames = [];
+ this._methodsParams = _.extend({}, extraParams);
+ this._methodsParams.optionsPossibleValues = {};
+ this._dependencies = [];
+ this._triggerWidgetsNames = [];
+ this._triggerWidgetsValues = [];
+
+ for (const key in this.el.dataset) {
+ const dataValue = this.el.dataset[key].trim();
+
+ if (key === 'dependencies') {
+ this._dependencies.push(...dataValue.split(/\s*,\s*/g));
+ } else if (key === 'trigger') {
+ this._triggerWidgetsNames.push(...dataValue.split(/\s*,\s*/g));
+ } else if (key === 'triggerValue') {
+ this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g));
+ } else if (validMethodNames.includes(key)) {
+ this._methodsNames.push(key);
+ this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g);
+ } else {
+ this._methodsParams[key] = dataValue;
+ }
+ }
+ this._userValueWidgets.forEach(widget => {
+ const inheritedParams = _.extend({}, this._methodsParams);
+ inheritedParams.optionsPossibleValues = null;
+ widget.loadMethodsData(validMethodNames, inheritedParams);
+ const subMethodsNames = widget.getMethodsNames();
+ const subMethodsParams = widget.getMethodsParams();
+
+ for (const methodName of subMethodsNames) {
+ if (!this._methodsNames.includes(methodName)) {
+ this._methodsNames.push(methodName);
+ this._methodsParams.optionsPossibleValues[methodName] = [];
+ }
+ for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) {
+ this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue);
+ }
+ }
+ });
+ for (const methodName of this._methodsNames) {
+ const arr = this._methodsParams.optionsPossibleValues[methodName];
+ const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v));
+ this._methodsParams.optionsPossibleValues[methodName] = uniqArr;
+ }
+ },
+ /**
+ * @param {boolean} [previewMode=false]
+ * @param {boolean} [isSimulatedEvent=false]
+ */
+ notifyValueChange: function (previewMode, isSimulatedEvent) {
+ // If the widget has no associated method, it should not notify user
+ // value changes
+ if (!this._methodsNames.length) {
+ return;
+ }
+
+ // In the case we notify a change update, force a preview update if it
+ // was not already previewed
+ const isPreviewed = this.isPreviewed();
+ if (!previewMode && !isPreviewed) {
+ this.notifyValueChange(true);
+ }
+
+ const data = {
+ previewMode: previewMode || false,
+ isSimulatedEvent: !!isSimulatedEvent,
+ };
+ // TODO improve this. The preview state has to be updated only when the
+ // actual option _select is gonna be called... but this is delayed by a
+ // mutex. So, during test tours, we would notify both 'preview' and
+ // 'reset' before the 'preview' handling is done: and so the widget
+ // would not be considered in preview during that 'preview' handling.
+ if (previewMode === true || previewMode === false) {
+ // Note: the widgets need to be considered in preview mode during
+ // non-preview handling (a previewed checkbox is considered having
+ // an inverted state)... but if, for example, a modal opens before
+ // handling that non-preview, a 'reset' will be thrown thus removing
+ // the preview class. So we force it in non-preview too.
+ data.prepare = () => this.el.classList.add('o_we_preview');
+ } else if (previewMode === 'reset') {
+ data.prepare = () => this.el.classList.remove('o_we_preview');
+ }
+
+ this.trigger_up('user_value_update', data);
+ },
+ /**
+ * Opens the widget (only meaningful for widgets that can be opened).
+ */
+ open() {
+ this.trigger_up('user_value_widget_opening');
+ this.el.classList.add('o_we_widget_opened');
+ },
+ /**
+ * Adds the given widget to the known list of user value sub-widgets (useful
+ * for container widgets).
+ *
+ * @param {UserValueWidget} widget
+ */
+ registerSubWidget: function (widget) {
+ this._userValueWidgets.push(widget);
+ },
+ /**
+ * Sets the user value that the widget should currently hold, for the
+ * given method name.
+ *
+ * Note: a widget typically only holds one value for the only method it
+ * supports. However, widgets can have several methods; in that case, the
+ * value is typically received for a first method and receiving the value
+ * for other ones should not affect the widget (otherwise, it means the
+ * methods are conflicting with each other).
+ *
+ * @param {string} value
+ * @param {string} [methodName]
+ */
+ async setValue(value, methodName) {
+ this._value = value;
+ this.el.classList.remove('o_we_preview');
+ },
+ /**
+ * @param {boolean} show
+ */
+ toggleVisibility: function (show) {
+ this.el.classList.toggle('d-none', !show);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent|Event}
+ * @returns {boolean}
+ */
+ _handleNotifierEvent: function (ev) {
+ if (!ev) {
+ return true;
+ }
+ if (ev._seen) {
+ return false;
+ }
+ ev._seen = true;
+ if (ev.preventDefault) {
+ ev.preventDefault();
+ }
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * change.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValueChange: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange(false);
+ }
+ },
+ /**
+ * Allows container widgets to add additional data if needed.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUserValueNotification: function (ev) {
+ ev.data.widget = this;
+
+ if (!ev.data.triggerWidgetsNames) {
+ ev.data.triggerWidgetsNames = [];
+ }
+ ev.data.triggerWidgetsNames.push(...this._triggerWidgetsNames);
+
+ if (!ev.data.triggerWidgetsValues) {
+ ev.data.triggerWidgetsValues = [];
+ }
+ ev.data.triggerWidgetsValues.push(...this._triggerWidgetsValues);
+ },
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * preview.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValuePreview: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange(true);
+ }
+ },
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * reset.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValueReset: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange('reset');
+ }
+ },
+});
+
+const ButtonUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-button',
+ events: {
+ 'click': '_onButtonClick',
+ 'click [role="button"]': '_onInnerButtonClick',
+ 'mouseenter': '_onUserValuePreview',
+ 'mouseleave': '_onUserValueReset',
+ },
+
+ /**
+ * @override
+ */
+ start: function (parent, title, options) {
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.containerEl.appendChild(node));
+ }
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getActiveValue: function (methodName) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ return possibleValues && possibleValues[possibleValues.length - 1] || '';
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ return (this.isPreviewed() !== this.el.classList.contains('active'));
+ },
+ /**
+ * @override
+ */
+ loadMethodsData: function (validMethodNames) {
+ this._super.apply(this, arguments);
+ for (const methodName of this._methodsNames) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ if (possibleValues.length <= 1) {
+ possibleValues.unshift('');
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ await this._super(...arguments);
+ let active = !!value;
+ if (methodName) {
+ if (!this._methodsNames.includes(methodName)) {
+ return;
+ }
+ active = (this.getActiveValue(methodName) === value);
+ }
+ this.el.classList.toggle('active', active);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onButtonClick: function (ev) {
+ if (!ev._innerButtonClicked) {
+ this._onUserValueChange(ev);
+ }
+ },
+ /**
+ * @private
+ */
+ _onInnerButtonClick: function (ev) {
+ // Cannot just stop propagation as the click needs to be propagated to
+ // potential parent widgets for event delegation on those inner buttons.
+ ev._innerButtonClicked = true;
+ },
+});
+
+const CheckboxUserValueWidget = ButtonUserValueWidget.extend({
+ className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper',
+
+ /**
+ * @override
+ */
+ start: function () {
+ const checkboxEl = document.createElement('we-checkbox');
+ this.containerEl.appendChild(checkboxEl);
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ enable() {
+ this.$('we-checkbox').click();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onButtonClick(ev) {
+ if (!ev.target.closest('we-title, we-checkbox')) {
+ // Only consider clicks on the label and the checkbox control itself
+ return;
+ }
+ return this._super(...arguments);
+ },
+});
+
+const BaseSelectionUserValueWidget = UserValueWidget.extend({
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ this.menuEl = document.createElement('we-selection-items');
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.menuEl.appendChild(node));
+ }
+ this.containerEl.appendChild(this.menuEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getMethodsParams(methodName) {
+ const params = this._super(...arguments);
+ const activeWidget = this._getActiveSubWidget();
+ if (!activeWidget) {
+ return params;
+ }
+ return Object.assign(activeWidget.getMethodsParams(...arguments), params);
+ },
+ /**
+ * @override
+ */
+ getValue(methodName) {
+ const activeWidget = this._getActiveSubWidget();
+ if (activeWidget) {
+ return activeWidget.getActiveValue(methodName);
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ isContainer() {
+ return true;
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ const _super = this._super.bind(this);
+ for (const widget of this._userValueWidgets) {
+ await widget.setValue(NULL_ID, methodName);
+ }
+ for (const widget of [...this._userValueWidgets].reverse()) {
+ await widget.setValue(value, methodName);
+ if (widget.isActive()) {
+ // Only one select item can be true at a time, we consider the
+ // last one if multiple would be active.
+ return;
+ }
+ }
+ await _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {UserValueWidget|undefined}
+ */
+ _getActiveSubWidget() {
+ const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed());
+ if (previewedWidget) {
+ return previewedWidget;
+ }
+ return this._userValueWidgets.find(widget => widget.isActive());
+ },
+});
+
+const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({
+ tagName: 'we-select',
+ events: {
+ 'click': '_onClick',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (this.options && this.options.valueEl) {
+ this.containerEl.insertBefore(this.options.valueEl, this.menuEl);
+ }
+
+ this.menuTogglerEl = document.createElement('we-toggler');
+ this.icon = this.el.dataset.icon || false;
+ if (this.icon) {
+ this.el.classList.add('o_we_icon_select');
+ const iconEl = document.createElement('i');
+ iconEl.classList.add('fa', 'fa-fw', this.icon);
+ this.menuTogglerEl.appendChild(iconEl);
+ }
+ this.containerEl.insertBefore(this.menuTogglerEl, this.menuEl);
+
+ const dropdownCaretEl = document.createElement('span');
+ dropdownCaretEl.classList.add('o_we_dropdown_caret');
+ this.containerEl.appendChild(dropdownCaretEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ close: function () {
+ this._super(...arguments);
+ if (this.menuTogglerEl) {
+ this.menuTogglerEl.classList.remove('active');
+ }
+ },
+ /**
+ * @override
+ */
+ isPreviewed: function () {
+ return this._super(...arguments) || this.menuTogglerEl.classList.contains('active');
+ },
+ /**
+ * @override
+ */
+ open() {
+ this._super(...arguments);
+ this.menuTogglerEl.classList.add('active');
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+
+ if (this.icon) {
+ return;
+ }
+
+ if (this.menuTogglerItemEl) {
+ this.menuTogglerItemEl.remove();
+ this.menuTogglerItemEl = null;
+ }
+
+ let textContent = '';
+ const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive());
+ if (activeWidget) {
+ const svgTag = activeWidget.el.querySelector('svg'); // useful to avoid searching text content in svg element
+ const value = (activeWidget.el.dataset.selectLabel || (!svgTag && activeWidget.el.textContent.trim()));
+ const imgSrc = activeWidget.el.dataset.img;
+ if (value) {
+ textContent = value;
+ } else if (imgSrc) {
+ this.menuTogglerItemEl = document.createElement('img');
+ this.menuTogglerItemEl.src = imgSrc;
+ } else {
+ const fakeImgEl = activeWidget.el.querySelector('.o_we_fake_img_item');
+ if (fakeImgEl) {
+ this.menuTogglerItemEl = fakeImgEl.cloneNode(true);
+ }
+ }
+ } else {
+ textContent = "/";
+ }
+
+ this.menuTogglerEl.textContent = textContent;
+ if (this.menuTogglerItemEl) {
+ this.menuTogglerEl.appendChild(this.menuTogglerItemEl);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _shouldIgnoreClick(ev) {
+ return !!ev.target.closest('[role="button"]');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the select is clicked anywhere -> open/close it.
+ *
+ * @private
+ */
+ _onClick: function (ev) {
+ if (this._shouldIgnoreClick(ev)) {
+ return;
+ }
+
+ if (!this.menuTogglerEl.classList.contains('active')) {
+ this.open();
+ } else {
+ this.close();
+ }
+ const activeButton = this._userValueWidgets.find(widget => widget.isActive());
+ if (activeButton) {
+ this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2);
+ }
+ },
+});
+
+const ButtonGroupUserValueWidget = BaseSelectionUserValueWidget.extend({
+ tagName: 'we-button-group',
+});
+
+const UnitUserValueWidget = UserValueWidget.extend({
+ /**
+ * @override
+ */
+ start: async function () {
+ const unit = this.el.dataset.unit || '';
+ this.el.dataset.unit = unit;
+ if (this.el.dataset.saveUnit === undefined) {
+ this.el.dataset.saveUnit = unit;
+ }
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getActiveValue: function (methodName) {
+ const activeValue = this._super(...arguments);
+
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return activeValue;
+ }
+
+ const defaultValue = this.getDefaultValue(methodName, false);
+
+ return activeValue.split(/\s+/g).map(v => {
+ const numValue = parseFloat(v);
+ if (isNaN(numValue)) {
+ return defaultValue;
+ } else {
+ const value = weUtils.convertNumericToUnit(numValue, params.unit, params.saveUnit, params.cssProperty, this.$target);
+ return `${this._floatToStr(value)}${params.saveUnit}`;
+ }
+ }).join(' ');
+ },
+ /**
+ * @override
+ * @param {boolean} [useInputUnit=false]
+ */
+ getDefaultValue: function (methodName, useInputUnit) {
+ const defaultValue = this._super(...arguments);
+
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return defaultValue;
+ }
+
+ const unit = useInputUnit ? params.unit : params.saveUnit;
+ const numValue = weUtils.convertValueToUnit(defaultValue || '0', unit, params.cssProperty, this.$target);
+ if (isNaN(numValue)) {
+ return defaultValue;
+ }
+ return `${this._floatToStr(numValue)}${unit}`;
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ const isSuperActive = this._super(...arguments);
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return isSuperActive;
+ }
+ return isSuperActive && this._floatToStr(parseFloat(this._value)) !== '0';
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ const params = this._methodsParams;
+ if (params.unit) {
+ value = value.split(' ').map(v => {
+ const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target);
+ if (isNaN(numValue)) {
+ return ''; // Something not supported
+ }
+ return this._floatToStr(numValue);
+ }).join(' ');
+ }
+ return this._super(value, methodName);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts a floating value to a string, rounded to 5 digits without zeros.
+ *
+ * @private
+ * @param {number} value
+ * @returns {string}
+ */
+ _floatToStr: function (value) {
+ return `${parseFloat(value.toFixed(5))}`;
+ },
+});
+
+const InputUserValueWidget = UnitUserValueWidget.extend({
+ tagName: 'we-input',
+ events: {
+ 'input input': '_onInputInput',
+ 'blur input': '_onInputBlur',
+ 'keydown input': '_onInputKeydown',
+ },
+
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ const unit = this.el.dataset.unit;
+ this.inputEl = document.createElement('input');
+ this.inputEl.setAttribute('type', 'text');
+ this.inputEl.setAttribute('autocomplete', 'chrome-off');
+ this.inputEl.setAttribute('placeholder', this.el.getAttribute('placeholder') || '');
+ this.inputEl.classList.toggle('text-left', !unit);
+ this.inputEl.classList.toggle('text-right', !!unit);
+ this.containerEl.appendChild(this.inputEl);
+
+ var unitEl = document.createElement('span');
+ unitEl.textContent = unit;
+ this.containerEl.appendChild(unitEl);
+ if (unit.length > 3) {
+ this.el.classList.add('o_we_large_input');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ this.inputEl.value = this._value;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputInput: function (ev) {
+ this._value = this.inputEl.value;
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputBlur: function (ev) {
+ // Sometimes, an input is focusout for internal reason (like an undo
+ // recording) then focused again manually in the same JS stack
+ // execution. In that case, the blur should not trigger an option
+ // selection as the user did not leave the input. We thus defer the blur
+ // handling to then check that the target is indeed still blurred before
+ // executing the actual option selection.
+ setTimeout(() => {
+ if (ev.currentTarget === document.activeElement) {
+ return;
+ }
+ this._onUserValueChange(ev);
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.ENTER: {
+ this._onUserValueChange(ev);
+ break;
+ }
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN: {
+ const input = ev.currentTarget;
+ const params = this._methodsParams;
+ if (!params.unit && !params.step) {
+ break;
+ }
+ let value = parseFloat(input.value || input.placeholder);
+ if (isNaN(value)) {
+ value = 0.0;
+ }
+ let step = parseFloat(params.step);
+ if (isNaN(step)) {
+ step = 1.0;
+ }
+ value += (ev.which === $.ui.keyCode.UP ? step : -step);
+ input.value = this._floatToStr(value);
+ $(input).trigger('input');
+ break;
+ }
+ }
+ },
+});
+
+const MultiUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-multi',
+
+ /**
+ * @override
+ */
+ start: function () {
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.containerEl.appendChild(node));
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getValue: function (methodName) {
+ const value = this._userValueWidgets.map(widget => {
+ return widget.getValue(methodName);
+ }).join(' ').trim();
+
+ return value || this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ isContainer: function () {
+ return true;
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ let values = value.split(/\s*\|\s*/g);
+ if (values.length === 1) {
+ values = value.split(/\s+/g);
+ }
+ for (let i = 0; i < this._userValueWidgets.length - 1; i++) {
+ await this._userValueWidgets[i].setValue(values.shift() || '', methodName);
+ }
+ await this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName);
+ },
+});
+
+const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
+ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette',
+ custom_events: _.extend({}, SelectUserValueWidget.prototype.custom_events, {
+ 'custom_color_picked': '_onCustomColorPicked',
+ 'color_picked': '_onColorPicked',
+ 'color_hover': '_onColorHovered',
+ 'color_leave': '_onColorLeft',
+ 'enter_key_color_colorpicker': '_onEnterKey'
+ }),
+
+ /**
+ * @override
+ */
+ start: async function () {
+ const _super = this._super.bind(this);
+ const args = arguments;
+
+ if (this.options.dataAttributes.lazyPalette === 'true') {
+ // TODO review in master, this was done in stable to keep the speed
+ // fix as stable as possible (to have a reference to a widget even
+ // if not a colorPalette widget).
+ this.colorPalette = new Widget(this);
+ this.colorPalette.getColorNames = () => [];
+ await this.colorPalette.appendTo(document.createDocumentFragment());
+ } else {
+ await this._renderColorPalette();
+ }
+
+ // Build the select element with a custom span to hold the color preview
+ this.colorPreviewEl = document.createElement('span');
+ this.colorPreviewEl.classList.add('o_we_color_preview');
+ this.options.childNodes = [this.colorPalette.el];
+ this.options.valueEl = this.colorPreviewEl;
+
+ return _super(...args);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ open: function () {
+ if (this.colorPalette.setSelectedColor) {
+ this.colorPalette.setSelectedColor(this._value);
+ } else {
+ // TODO review in master, this does async stuff. Maybe the open
+ // method should now be async. This is not really robust as the
+ // colorPalette can be used without it to be fully rendered but
+ // the use of the saved promise where we can should mitigate that
+ // issue.
+ this._colorPaletteRenderPromise = this._renderColorPalette();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ close: function () {
+ this._super(...arguments);
+ if (this._customColorValue && this._customColorValue !== this._value) {
+ this._value = this._customColorValue;
+ this._customColorValue = false;
+ this._onUserValueChange();
+ }
+ },
+ /**
+ * @override
+ */
+ getMethodsParams: function () {
+ return _.extend(this._super(...arguments), {
+ colorNames: this.colorPalette.getColorNames(),
+ });
+ },
+ /**
+ * @override
+ */
+ getValue: function (methodName) {
+ if (typeof this._previewColor === 'string') {
+ return this._previewColor;
+ }
+ if (typeof this._customColorValue === 'string') {
+ return this._customColorValue;
+ }
+ let value = this._super(...arguments);
+ if (value) {
+ const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor');
+ const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible');
+ if ((useCssColor || cssCompatible) && !ColorpickerWidget.isCSSColor(value)) {
+ if (useCssColor) {
+ value = weUtils.getCSSVariableValue(value);
+ } else {
+ value = `var(--${value})`;
+ }
+ }
+ }
+ return value;
+ },
+ /**
+ * @override
+ */
+ isContainer: function () {
+ return false;
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ return !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)');
+ },
+ /**
+ * Updates the color preview + re-render the whole color palette widget.
+ *
+ * @override
+ */
+ async setValue(color) {
+ await this._super(...arguments);
+
+ await this._colorPaletteRenderPromise;
+
+ const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames());
+ this.colorPreviewEl.classList.remove(...classes);
+ this.colorPreviewEl.style.removeProperty('background-color');
+
+ if (this._value) {
+ if (ColorpickerWidget.isCSSColor(this._value)) {
+ this.colorPreviewEl.style.backgroundColor = this._value;
+ } else if (weUtils.isColorCombinationName(this._value)) {
+ this.colorPreviewEl.classList.add('o_cc', `o_cc${this._value}`);
+ } else {
+ this.colorPreviewEl.classList.add(`bg-${this._value}`);
+ }
+ }
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _renderColorPalette: function () {
+ const options = {
+ selectedColor: this._value,
+ };
+ if (this.options.dataAttributes.excluded) {
+ options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(',');
+ }
+ if (this.options.dataAttributes.withCombinations) {
+ options.withCombinations = !!this.options.dataAttributes.withCombinations;
+ }
+ const oldColorPalette = this.colorPalette;
+ this.colorPalette = new ColorPaletteWidget(this, options);
+ if (oldColorPalette) {
+ return this.colorPalette.insertAfter(oldColorPalette.el).then(() => {
+ oldColorPalette.destroy();
+ });
+ }
+ return this.colorPalette.appendTo(document.createDocumentFragment());
+ },
+ /**
+ * @override
+ */
+ _shouldIgnoreClick(ev) {
+ return ev.originalEvent.__isColorpickerClick || this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a custom color is selected -> preview the color
+ * and set the current value. Update of this value on close
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCustomColorPicked: function (ev) {
+ this._customColorValue = ev.data.color;
+ },
+ /**
+ * Called when a color button is clicked -> confirms the preview.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPicked: function (ev) {
+ this._previewColor = false;
+ this._customColorValue = false;
+ this._value = ev.data.color;
+ this._onUserValueChange(ev);
+ },
+ /**
+ * Called when a color button is entered -> previews the background color.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorHovered: function (ev) {
+ this._previewColor = ev.data.color;
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * Called when a color button is left -> cancels the preview.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorLeft: function (ev) {
+ this._previewColor = false;
+ this._onUserValueReset(ev);
+ },
+ /**
+ * @private
+ */
+ _onEnterKey: function () {
+ this.close();
+ },
+});
+
+const MediapickerUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-button',
+ events: {
+ 'click': '_onEditMedia',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ const iconEl = document.createElement('i');
+ if (this.options.dataAttributes.buttonStyle) {
+ iconEl.classList.add('fa', 'fa-fw', 'fa-camera');
+ } else {
+ iconEl.classList.add('fa', 'fa-fw', 'fa-refresh', 'mr-1');
+ this.el.classList.add('o_we_no_toggle');
+ this.containerEl.textContent = _t("Replace media");
+ }
+ $(this.containerEl).prepend(iconEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates and opens a media dialog to edit a given element's media.
+ *
+ * @private
+ * @param {HTMLElement} el the element whose media should be edited
+ * @param {boolean} [images] whether images should be available
+ * default: false
+ * @param {boolean} [videos] whether videos should be available
+ * default: false
+ */
+ _openDialog(el, {images = false, videos = false}) {
+ el.src = this._value;
+ const $editable = this.$target.closest('.o_editable');
+ const mediaDialog = new weWidgets.MediaDialog(this, {
+ noImages: !images,
+ noVideos: !videos,
+ noIcons: true,
+ noDocuments: true,
+ isForBgVideo: true,
+ 'res_model': $editable.data('oe-model'),
+ 'res_id': $editable.data('oe-id'),
+ }, el).open();
+ return mediaDialog;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ this.el.classList.toggle('active', this.isActive());
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the edit button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onEditMedia: function (ev) {},
+});
+
+const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onEditMedia(ev) {
+ // Need a dummy element for the media dialog to modify.
+ const dummyEl = document.createElement('img');
+ const dialog = this._openDialog(dummyEl, {images: true});
+ dialog.on('save', this, data => {
+ // Accessing the value directly through dummyEl.src converts the url to absolute,
+ // using getAttribute allows us to keep the url as it was inserted in the DOM
+ // which can be useful to compare it to values stored in db.
+ this._value = dummyEl.getAttribute('src');
+ this._onUserValueChange();
+ });
+ },
+});
+
+const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onEditMedia(ev) {
+ // Need a dummy element for the media dialog to modify.
+ const dummyEl = document.createElement('iframe');
+ const dialog = this._openDialog(dummyEl, {videos: true});
+ dialog.on('save', this, data => {
+ this._value = data.bgVideoSrc;
+ this._onUserValueChange();
+ });
+ },
+});
+
+const DatetimePickerUserValueWidget = InputUserValueWidget.extend({
+ events: { // Explicitely not consider all InputUserValueWidget events
+ 'blur input': '_onInputBlur',
+ 'change.datetimepicker': '_onDateTimePickerChange',
+ 'error.datetimepicker': '_onDateTimePickerError',
+ },
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super(...arguments);
+ this._value = moment().unix().toString();
+ this.__libInput = 0;
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ const datetimePickerId = _.uniqueId('datetimepicker');
+ this.el.classList.add('o_we_large_input');
+ this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-left');
+ this.inputEl.setAttribute('id', datetimePickerId);
+ this.inputEl.setAttribute('data-target', '#' + datetimePickerId);
+
+ const datepickersOptions = {
+ minDate: moment({ y: 1000 }),
+ maxDate: moment().add(200, 'y'),
+ calendarWeeks: true,
+ defaultDate: moment().format(),
+ icons: {
+ close: 'fa fa-check primary',
+ },
+ locale: moment.locale(),
+ format: time.getLangDatetimeFormat(),
+ sideBySide: true,
+ buttons: {
+ showClose: true,
+ showToday: true,
+ },
+ widgetParent: 'body',
+
+ // Open the datetimepicker on focus not on click. This allows to
+ // take care of a bug which is due to the summernote editor:
+ // sometimes, the datetimepicker loses the focus then get it back
+ // in the same execution flow. This was making the datepicker close
+ // for no apparent reason. Now, it only closes then reopens directly
+ // without it be possible to notice.
+ allowInputToggle: true,
+ };
+ this.__libInput++;
+ const $input = $(this.inputEl);
+ $input.datetimepicker(datepickersOptions);
+ this.__libInput--;
+
+ // Monkey-patch the library option to add custom classes on the pickers
+ const libObject = $input.data('datetimepicker');
+ const oldFunc = libObject._getTemplate;
+ libObject._getTemplate = function () {
+ const $template = oldFunc.call(this, ...arguments);
+ $template.addClass('o_we_no_overlay o_we_datetimepicker');
+ return $template;
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isPreviewed: function () {
+ return this._super(...arguments) || !!$(this.inputEl).data('datetimepicker').widget;
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ let momentObj = moment.unix(this._value);
+ if (!momentObj.isValid()) {
+ momentObj = moment();
+ }
+ this.__libInput++;
+ $(this.inputEl).datetimepicker('date', momentObj);
+ this.__libInput--;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onDateTimePickerChange: function (ev) {
+ if (this.__libInput > 0) {
+ return;
+ }
+ if (!ev.date || !ev.date.isValid()) {
+ return;
+ }
+ this._value = ev.date.unix().toString();
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * Prevents crash manager to throw CORS error. Note that library already
+ * clears the wrong date format.
+ */
+ _onDateTimePickerError: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+const RangeUserValueWidget = UnitUserValueWidget.extend({
+ tagName: 'we-range',
+ events: {
+ 'change input': '_onInputChange',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.input = document.createElement('input');
+ this.input.type = "range";
+ let min = this.el.dataset.min && parseFloat(this.el.dataset.min) || 0;
+ let max = this.el.dataset.max && parseFloat(this.el.dataset.max) || 100;
+ const step = this.el.dataset.step && parseFloat(this.el.dataset.step) || 1;
+ if (min > max) {
+ [min, max] = [max, min];
+ this.input.classList.add('o_we_inverted_range');
+ }
+ this.input.setAttribute('min', min);
+ this.input.setAttribute('max', max);
+ this.input.setAttribute('step', step);
+ this.containerEl.appendChild(this.input);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ await this._super(...arguments);
+ this.input.value = this._value;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputChange(ev) {
+ this._value = ev.target.value;
+ this._onUserValueChange(ev);
+ },
+});
+
+const SelectPagerUserValueWidget = SelectUserValueWidget.extend({
+ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager',
+ events: Object.assign({}, SelectUserValueWidget.prototype.events, {
+ 'click .o_we_pager_next, .o_we_pager_prev': '_onPageChange',
+ }),
+
+ /**
+ * @override
+ */
+ async start() {
+ const _super = this._super.bind(this);
+ this.pages = this.options.childNodes.filter(node => node.matches && node.matches('we-select-page'));
+ this.numPages = this.pages.length;
+
+ const prev = document.createElement('i');
+ prev.classList.add('o_we_pager_prev', 'fa', 'fa-chevron-left');
+
+ this.pageNum = document.createElement('span');
+ this.currentPage = 0;
+
+ const next = document.createElement('i');
+ next.classList.add('o_we_pager_next', 'fa', 'fa-chevron-right');
+
+ const pagerControls = document.createElement('div');
+ pagerControls.classList.add('o_we_pager_controls');
+ pagerControls.appendChild(prev);
+ pagerControls.appendChild(this.pageNum);
+ pagerControls.appendChild(next);
+
+ this.pageName = document.createElement('b');
+ const pagerHeader = document.createElement('div');
+ pagerHeader.classList.add('o_we_pager_header');
+ pagerHeader.appendChild(this.pageName);
+ pagerHeader.appendChild(pagerControls);
+
+ await _super(...arguments);
+ this.menuEl.classList.add('o_we_has_pager');
+ $(this.menuEl).prepend(pagerHeader);
+ this._updatePage();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _shouldIgnoreClick(ev) {
+ return !!ev.target.closest('.o_we_pager_header') || this._super(...arguments);
+ },
+ /**
+ * Updates the pager's page number display.
+ *
+ * @private
+ */
+ _updatePage() {
+ this.pages.forEach((page, i) => page.classList.toggle('active', i === this.currentPage));
+ this.pageNum.textContent = `${this.currentPage + 1}/${this.numPages}`;
+ const activePage = this.pages.find((page, i) => i === this.currentPage);
+ this.pageName.textContent = activePage.getAttribute('string');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Goes to the previous/next page with wrap-around.
+ *
+ * @private
+ */
+ _onPageChange(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const delta = ev.target.matches('.o_we_pager_next') ? 1 : -1;
+ this.currentPage = (this.currentPage + this.numPages + delta) % this.numPages;
+ this._updatePage();
+ },
+ /**
+ * @override
+ */
+ _onClick(ev) {
+ const activeButton = this._getActiveSubWidget();
+ if (activeButton) {
+ const currentPage = this.pages.indexOf(activeButton.el.closest('we-select-page'));
+ if (currentPage !== -1) {
+ this.currentPage = currentPage;
+ this._updatePage();
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+
+const userValueWidgetsRegistry = {
+ 'we-button': ButtonUserValueWidget,
+ 'we-checkbox': CheckboxUserValueWidget,
+ 'we-select': SelectUserValueWidget,
+ 'we-button-group': ButtonGroupUserValueWidget,
+ 'we-input': InputUserValueWidget,
+ 'we-multi': MultiUserValueWidget,
+ 'we-colorpicker': ColorpickerUserValueWidget,
+ 'we-datetimepicker': DatetimePickerUserValueWidget,
+ 'we-imagepicker': ImagepickerUserValueWidget,
+ 'we-videopicker': VideopickerUserValueWidget,
+ 'we-range': RangeUserValueWidget,
+ 'we-select-pager': SelectPagerUserValueWidget,
+};
+
+/**
+ * Handles a set of options for one snippet. The registry returned by this
+ * module contains the names of the specialized SnippetOptionWidget which can be
+ * referenced thanks to the data-js key in the web_editor options template.
+ */
+const SnippetOptionWidget = Widget.extend({
+ tagName: 'we-customizeblock-option',
+ events: {
+ 'click .o_we_collapse_toggler': '_onCollapseTogglerClick',
+ },
+ custom_events: {
+ 'user_value_update': '_onUserValueUpdate',
+ 'user_value_widget_critical': '_onUserValueWidgetCritical',
+ },
+ /**
+ * Indicates if the option should be displayed in the button group at the
+ * top of the options panel, next to the clone/remove button.
+ *
+ * @type {boolean}
+ */
+ isTopOption: false,
+ /**
+ * Forces the target to not be possible to remove.
+ *
+ * @type {boolean}
+ */
+ forceNoDeleteButton: false,
+
+ /**
+ * The option `$el` is supposed to be the associated DOM UI element.
+ * 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, $uiElements, $target, $overlay, data, options) {
+ this._super.apply(this, arguments);
+
+ this.$originalUIElements = $uiElements;
+
+ this.$target = $target;
+ this.$overlay = $overlay;
+ this.data = data;
+ this.options = options;
+
+ this.className = 'snippet-option-' + this.data.optionName;
+
+ this.ownerDocument = this.$target[0].ownerDocument;
+
+ this._userValueWidgets = [];
+ this._actionQueues = new Map();
+ },
+ /**
+ * @override
+ */
+ willStart: async function () {
+ await this._super(...arguments);
+ return this._renderOriginalXML().then(uiFragment => {
+ this.uiFragment = uiFragment;
+ });
+ },
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super(...arguments);
+ this.el.appendChild(this.uiFragment);
+ this.uiFragment = null;
+ },
+ /**
+ * 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 target is shown, only meaningful if the target was hidden
+ * at some point (typically used for 'invisible' snippets).
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ onTargetShow: async function () {},
+ /**
+ * Called when the target is hidden (typically used for 'invisible'
+ * snippets).
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ onTargetHide: async function () {},
+ /**
+ * Called when the template which contains the associated snippet is about
+ * to be saved.
+ *
+ * @abstract
+ * @return {Promise|undefined}
+ */
+ cleanForSave: async 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 select with each item having a `data-select-class`
+ * value allowing to choose the associated class, or simply an unique
+ * checkbox to allow toggling a unique 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 {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectClass: function (previewMode, widgetValue, params) {
+ for (const classNames of params.possibleValues) {
+ if (classNames) {
+ this.$target[0].classList.remove(...classNames.trim().split(/\s+/g));
+ }
+ }
+ if (widgetValue) {
+ this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g));
+ }
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as a data attribute. The name of the data attribute is
+ * given by the attributeName parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectDataAttribute: function (previewMode, widgetValue, params) {
+ const value = this._selectAttributeHelper(widgetValue, params);
+ this.$target[0].dataset[params.attributeName] = value;
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as an attribute. The name of the attribute is
+ * given by the attributeName parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectAttribute: function (previewMode, widgetValue, params) {
+ const value = this._selectAttributeHelper(widgetValue, params);
+ this.$target[0].setAttribute(params.attributeName, value);
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as a css style. The name of the css property is
+ * given by the cssProperty parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectStyle: function (previewMode, widgetValue, params) {
+ // Disable all transitions for the duration of the method as many
+ // comparisons will be done on the element to know if applying a
+ // property has an effect or not. Also, changing a css property via the
+ // editor should not show any transition as previews would not be done
+ // immediately, which is not good for the user experience.
+ this.$target[0].classList.add('o_we_force_no_transition');
+ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition');
+
+ if (params.cssProperty === 'background-color') {
+ this.$target.trigger('background-color-event', previewMode);
+ }
+
+ const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty];
+ for (const cssProp of cssProps) {
+ // Always reset the inline style first to not put inline style on an
+ // element which already have this style through css stylesheets.
+ this.$target[0].style.setProperty(cssProp, '');
+ }
+ if (params.extraClass) {
+ this.$target.removeClass(params.extraClass);
+ }
+
+ // Only allow to use a color name as a className if we know about the
+ // other potential color names (to remove) and if we know about a prefix
+ // (otherwise we suppose that we should use the actual related color).
+ if (params.colorNames && params.colorPrefix) {
+ const classes = weUtils.computeColorClasses(params.colorNames, params.colorPrefix);
+ this.$target[0].classList.remove(...classes);
+
+ if (weUtils.isColorCombinationName(widgetValue)) {
+ // Those are the special color combinations classes. Just have
+ // to add it (and adding the potential extra class) then leave.
+ this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`, params.extraClass);
+ _restoreTransitions();
+ return;
+ }
+ if (params.colorNames.includes(widgetValue)) {
+ const originalCSSValue = window.getComputedStyle(this.$target[0])[cssProps[0]];
+ const className = params.colorPrefix + widgetValue;
+ this.$target[0].classList.add(className);
+ if (originalCSSValue !== window.getComputedStyle(this.$target[0])[cssProps[0]]) {
+ // If applying the class did indeed changed the css
+ // property we are editing, nothing more has to be done.
+ // (except adding the extra class)
+ this.$target.addClass(params.extraClass);
+ _restoreTransitions();
+ return;
+ }
+ // Otherwise, it means that class probably does not exist,
+ // we remove it and continue. Especially useful for some
+ // prefixes which only work with some color names but not all.
+ this.$target[0].classList.remove(className);
+ }
+ }
+
+ // At this point, the widget value is either a property/color name or
+ // an actual css property value. If it is a property/color name, we will
+ // apply a css variable as style value.
+ const htmlPropValue = weUtils.getCSSVariableValue(widgetValue);
+ if (htmlPropValue) {
+ widgetValue = `var(--${widgetValue})`;
+ }
+
+ // replacing ', ' by ',' to prevent attributes with internal space separators from being split:
+ // eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"]
+ const values = widgetValue.replace(/,\s/g, ',').split(/\s+/g);
+ while (values.length < cssProps.length) {
+ switch (values.length) {
+ case 1:
+ case 2: {
+ values.push(values[0]);
+ break;
+ }
+ case 3: {
+ values.push(values[1]);
+ break;
+ }
+ default: {
+ values.push(values[values.length - 1]);
+ }
+ }
+ }
+
+ const styles = window.getComputedStyle(this.$target[0]);
+ let hasUserValue = false;
+ for (let i = cssProps.length - 1; i > 0; i--) {
+ hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue;
+ }
+ hasUserValue = applyCSS.call(this, cssProps[0], values.join(' '), styles) || hasUserValue;
+
+ function applyCSS(cssProp, cssValue, styles) {
+ if (!weUtils.areCssValuesEqual(styles[cssProp], cssValue)) {
+ this.$target[0].style.setProperty(cssProp, cssValue, 'important');
+ return true;
+ }
+ return false;
+ }
+
+ if (params.extraClass) {
+ this.$target.toggleClass(params.extraClass, hasUserValue);
+ }
+
+ _restoreTransitions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override the helper method to search inside the $target element instead
+ * of the UI item element.
+ *
+ * @override
+ */
+ $: function () {
+ return this.$target.find.apply(this.$target, arguments);
+ },
+ /**
+ * Closes all user value widgets.
+ */
+ closeWidgets: function () {
+ this._userValueWidgets.forEach(widget => widget.close());
+ },
+ /**
+ * @param {string} name
+ * @returns {UserValueWidget|null}
+ */
+ findWidget: function (name) {
+ for (const widget of this._userValueWidgets) {
+ if (widget.getName() === name) {
+ return widget;
+ }
+ const depWidget = widget.findWidget(name);
+ if (depWidget) {
+ return depWidget;
+ }
+ }
+ return null;
+ },
+ /**
+ * 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
+ * @returns {Promise}
+ */
+ 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.
+ *
+ * Note: the UI is not updated accordindly automatically.
+ *
+ * @param {jQuery} $target - the new target element
+ * @returns {Promise}
+ */
+ setTarget: function ($target) {
+ this.$target = $target;
+ },
+ /**
+ * Updates the UI. For widget update, @see _computeWidgetState.
+ *
+ * @param {boolean} [noVisibility=false]
+ * If true, only update widget values and their UI, not their visibility
+ * -> @see updateUIVisibility for toggling visibility only
+ * @returns {Promise}
+ */
+ updateUI: async function ({noVisibility} = {}) {
+ // For each widget, for each of their option method, notify to the
+ // widget the current value they should hold according to the $target's
+ // current state, related for that method.
+ const proms = this._userValueWidgets.map(async widget => {
+ // Update widget value (for each method)
+ const methodsNames = widget.getMethodsNames();
+ for (const methodName of methodsNames) {
+ const params = widget.getMethodsParams(methodName);
+
+ let obj = this;
+ if (params.applyTo) {
+ const $firstSubTarget = this.$(params.applyTo).eq(0);
+ if (!$firstSubTarget.length) {
+ continue;
+ }
+ obj = createPropertyProxy(this, '$target', $firstSubTarget);
+ }
+
+ const value = await this._computeWidgetState.call(obj, methodName, params);
+ if (value === undefined) {
+ continue;
+ }
+ const normalizedValue = this._normalizeWidgetValue(value);
+ await widget.setValue(normalizedValue, methodName);
+ }
+ });
+ await Promise.all(proms);
+
+ if (!noVisibility) {
+ await this.updateUIVisibility();
+ }
+ },
+ /**
+ * Updates the UI visibility - @see _computeVisibility. For widget update,
+ * @see _computeWidgetVisibility.
+ *
+ * @returns {Promise}
+ */
+ updateUIVisibility: async function () {
+ const proms = this._userValueWidgets.map(async widget => {
+ const params = widget.getMethodsParams();
+
+ let obj = this;
+ if (params.applyTo) {
+ const $firstSubTarget = this.$(params.applyTo).eq(0);
+ if (!$firstSubTarget.length) {
+ widget.toggleVisibility(false);
+ return;
+ }
+ obj = createPropertyProxy(this, '$target', $firstSubTarget);
+ }
+
+ // Make sure to check the visibility of all sub-widgets. For
+ // simplicity and efficiency, those will be checked with main
+ // widgets params.
+ const allSubWidgets = [widget];
+ let i = 0;
+ while (i < allSubWidgets.length) {
+ allSubWidgets.push(...allSubWidgets[i]._userValueWidgets);
+ i++;
+ }
+ const proms = allSubWidgets.map(async widget => {
+ const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params);
+ if (!show) {
+ widget.toggleVisibility(false);
+ return;
+ }
+
+ const dependencies = widget.getDependencies();
+ const dependenciesData = [];
+ dependencies.forEach(depName => {
+ const toBeActive = (depName[0] !== '!');
+ if (!toBeActive) {
+ depName = depName.substr(1);
+ }
+
+ const widget = this._requestUserValueWidgets(depName)[0];
+ if (widget) {
+ dependenciesData.push({
+ widget: widget,
+ toBeActive: toBeActive,
+ });
+ }
+ });
+ const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => {
+ return (depData.widget.isActive() === depData.toBeActive);
+ });
+
+ widget.toggleVisibility(dependenciesOK);
+ });
+ return Promise.all(proms);
+ });
+
+ const showUI = await this._computeVisibility();
+ this.el.classList.toggle('d-none', !showUI);
+
+ await Promise.all(proms);
+
+ // Hide layouting elements which contains only hidden widgets
+ // TODO improve this, this is hackish to rely on DOM structure here.
+ // Layouting elements should be handled as widgets or other.
+ for (const el of this.$el.find('we-row')) {
+ el.classList.toggle('d-none', !$(el).find('> div > .o_we_user_value_widget').not('.d-none').length);
+ }
+ for (const el of this.$el.find('we-collapse')) {
+ const $el = $(el);
+ el.classList.toggle('d-none', $el.children().first().hasClass('d-none'));
+ const hasNoVisibleElInCollapseMenu = !$el.children().last().children().not('.d-none').length;
+ if (hasNoVisibleElInCollapseMenu) {
+ this._toggleCollapseEl(el, false);
+ }
+ el.querySelector('.o_we_collapse_toggler').classList.toggle('d-none', hasNoVisibleElInCollapseMenu);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {UserValueWidget[]} widgets
+ * @returns {Promise<string>}
+ */
+ async _checkIfWidgetsUpdateNeedWarning(widgets) {
+ const messages = [];
+ for (const widget of widgets) {
+ const message = widget.getMethodsParams().warnMessage;
+ if (message) {
+ messages.push(message);
+ }
+ }
+ return messages.join(' ');
+ },
+ /**
+ * @private
+ * @param {UserValueWidget[]} widgets
+ * @returns {Promise<boolean|string>}
+ */
+ async _checkIfWidgetsUpdateNeedReload(widgets) {
+ return false;
+ },
+ /**
+ * @private
+ * @returns {Promise<boolean>|boolean}
+ */
+ _computeVisibility: async function () {
+ return true;
+ },
+ /**
+ * Returns the string value that should be hold by the widget which is
+ * related to the given method name.
+ *
+ * If the value is irrelevant for a method, it must return undefined.
+ *
+ * @private
+ * @param {string} methodName
+ * @param {Object} params
+ * @returns {Promise<string|undefined>|string|undefined}
+ */
+ _computeWidgetState: async function (methodName, params) {
+ switch (methodName) {
+ case 'selectClass': {
+ let maxNbClasses = 0;
+ let activeClassNames = '';
+ params.possibleValues.forEach(classNames => {
+ if (!classNames) {
+ return;
+ }
+ const classes = classNames.split(/\s+/g);
+ if (classes.length >= maxNbClasses
+ && classes.every(className => this.$target[0].classList.contains(className))) {
+ maxNbClasses = classes.length;
+ activeClassNames = classNames;
+ }
+ });
+ return activeClassNames;
+ }
+ case 'selectAttribute':
+ case 'selectDataAttribute': {
+ const attrName = params.attributeName;
+ let attrValue;
+ if (methodName === 'selectAttribute') {
+ attrValue = this.$target[0].getAttribute(attrName);
+ } else if (methodName === 'selectDataAttribute') {
+ attrValue = this.$target[0].dataset[attrName];
+ }
+ attrValue = (attrValue || '').trim();
+ if (params.saveUnit && !params.withUnit) {
+ attrValue = attrValue.split(/\s+/g).map(v => v + params.saveUnit).join(' ');
+ }
+ return attrValue || params.attributeDefaultValue || '';
+ }
+ case 'selectStyle': {
+ if (params.colorPrefix && params.colorNames) {
+ for (const c of params.colorNames) {
+ const className = weUtils.computeColorClasses([c], params.colorPrefix)[0];
+ if (this.$target[0].classList.contains(className)) {
+ return c;
+ }
+ }
+ }
+
+ // Disable all transitions for the duration of the style check
+ // as we want to know the final value of a property to properly
+ // update the UI.
+ this.$target[0].classList.add('o_we_force_no_transition');
+ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition');
+
+ const styles = window.getComputedStyle(this.$target[0]);
+ const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty];
+ const cssValues = cssProps.map(cssProp => {
+ let value = styles[cssProp].trim();
+ if (cssProp === 'box-shadow') {
+ const inset = value.includes('inset');
+ let values = value.replace(/,\s/g, ',').replace('inset', '').trim().split(/\s+/g);
+ const color = values.find(s => !s.match(/^\d/));
+ values = values.join(' ').replace(color, '').trim();
+ value = `${color} ${values}${inset ? ' inset' : ''}`;
+ }
+ return value;
+ });
+ if (cssValues.length === 4 && weUtils.areCssValuesEqual(cssValues[3], cssValues[1], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+ if (cssValues.length === 3 && weUtils.areCssValuesEqual(cssValues[2], cssValues[0], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+ if (cssValues.length === 2 && weUtils.areCssValuesEqual(cssValues[1], cssValues[0], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+
+ _restoreTransitions();
+
+ return cssValues.join(' ');
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {string} widgetName
+ * @param {Object} params
+ * @returns {Promise<boolean>|boolean}
+ */
+ _computeWidgetVisibility: async function (widgetName, params) {
+ if (widgetName === 'move_up_opt' || widgetName === 'move_left_opt') {
+ return !this.$target.is(':first-child');
+ }
+ if (widgetName === 'move_down_opt' || widgetName === 'move_right_opt') {
+ return !this.$target.is(':last-child');
+ }
+ return true;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} el
+ * @returns {Object}
+ */
+ _extraInfoFromDescriptionElement: function (el) {
+ return {
+ title: el.getAttribute('string'),
+ options: {
+ classes: el.classList,
+ dataAttributes: el.dataset,
+ tooltip: el.title,
+ placeholder: el.getAttribute('placeholder'),
+ childNodes: [...el.childNodes],
+ },
+ };
+ },
+ /**
+ * @private
+ * @param {*}
+ * @returns {string}
+ */
+ _normalizeWidgetValue: function (value) {
+ value = `${value}`.trim(); // Force to a trimmed string
+ value = ColorpickerWidget.normalizeCSSColor(value); // If is a css color, normalize it
+ return value;
+ },
+ /**
+ * @private
+ * @param {string} widgetName
+ * @param {UserValueWidget|this|null} parent
+ * @param {string} title
+ * @param {Object} options
+ * @returns {UserValueWidget}
+ */
+ _registerUserValueWidget: function (widgetName, parent, title, options) {
+ const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, this.$target);
+ if (!parent || parent === this) {
+ this._userValueWidgets.push(widget);
+ } else {
+ parent.registerSubWidget(widget);
+ }
+ return widget;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} uiFragment
+ * @returns {Promise}
+ */
+ _renderCustomWidgets: function (uiFragment) {
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {HTMLElement} uiFragment
+ * @returns {Promise}
+ */
+ _renderCustomXML: function (uiFragment) {
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {jQuery} [$xml] - default to original xml content
+ * @returns {Promise}
+ */
+ _renderOriginalXML: async function ($xml) {
+ const uiFragment = document.createDocumentFragment();
+ ($xml || this.$originalUIElements).clone(true).appendTo(uiFragment);
+
+ await this._renderCustomXML(uiFragment);
+
+ // Build layouting components first
+ for (const [itemName, build] of [['we-row', _buildRowElement], ['we-collapse', _buildCollapseElement]]) {
+ uiFragment.querySelectorAll(itemName).forEach(el => {
+ const infos = this._extraInfoFromDescriptionElement(el);
+ const groupEl = build(infos.title, infos.options);
+ el.parentNode.insertBefore(groupEl, el);
+ el.parentNode.removeChild(el);
+ });
+ }
+
+ // Load widgets
+ await this._renderXMLWidgets(uiFragment);
+ await this._renderCustomWidgets(uiFragment);
+
+ if (this.isDestroyed()) {
+ // TODO there is probably better to do. This case was found only in
+ // tours, where the editor is left before the widget are fully
+ // loaded (loadMethodsData doesn't work if the widget is destroyed).
+ return uiFragment;
+ }
+
+ const validMethodNames = [];
+ for (const key in this) {
+ validMethodNames.push(key);
+ }
+ this._userValueWidgets.forEach(widget => {
+ widget.loadMethodsData(validMethodNames);
+ });
+
+ return uiFragment;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} parentEl
+ * @param {SnippetOptionWidget|UserValueWidget} parentWidget
+ * @returns {Promise}
+ */
+ _renderXMLWidgets: function (parentEl, parentWidget) {
+ const proms = [...parentEl.children].map(el => {
+ const widgetName = el.tagName.toLowerCase();
+ if (!userValueWidgetsRegistry.hasOwnProperty(widgetName)) {
+ return this._renderXMLWidgets(el, parentWidget);
+ }
+
+ const infos = this._extraInfoFromDescriptionElement(el);
+ const widget = this._registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options);
+ return widget.insertAfter(el).then(() => {
+ // Remove the original element afterwards as the insertion
+ // operation may move some of its inner content during
+ // widget start.
+ parentEl.removeChild(el);
+
+ if (widget.isContainer()) {
+ return this._renderXMLWidgets(widget.el, widget);
+ }
+ });
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * @private
+ * @param {...string} widgetNames
+ * @returns {UserValueWidget[]}
+ */
+ _requestUserValueWidgets: function (...widgetNames) {
+ const widgets = [];
+ for (const widgetName of widgetNames) {
+ let widget = null;
+ this.trigger_up('user_value_widget_request', {
+ name: widgetName,
+ onSuccess: _widget => widget = _widget,
+ });
+ if (widget) {
+ widgets.push(widget);
+ }
+ }
+ return widgets;
+ },
+ /**
+ * @private
+ * @param {function<Promise<jQuery>>} [callback]
+ * @returns {Promise}
+ */
+ _rerenderXML: async function (callback) {
+ this._userValueWidgets.forEach(widget => widget.destroy());
+ this._userValueWidgets = [];
+ this.$el.empty();
+
+ let $xml = undefined;
+ if (callback) {
+ $xml = await callback.call(this);
+ }
+
+ return this._renderOriginalXML($xml).then(uiFragment => {
+ this.$el.append(uiFragment);
+ return this.updateUI();
+ });
+ },
+ /**
+ * 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 {UserValueWidget} widget - the widget which triggered the option change
+ * @returns {Promise}
+ */
+ _select: async function (previewMode, widget) {
+ let $applyTo = null;
+
+ // Call each option method sequentially
+ for (const methodName of widget.getMethodsNames()) {
+ const widgetValue = widget.getValue(methodName);
+ const params = widget.getMethodsParams(methodName);
+
+ if (params.applyTo) {
+ if (!$applyTo) {
+ $applyTo = this.$(params.applyTo);
+ }
+ const proms = _.map($applyTo, subTargetEl => {
+ const proxy = createPropertyProxy(this, '$target', $(subTargetEl));
+ return this[methodName].call(proxy, previewMode, widgetValue, params);
+ });
+ await Promise.all(proms);
+ } else {
+ await this[methodName](previewMode, widgetValue, params);
+ }
+ }
+
+ // We trigger the event on elements targeted by apply-to if any as
+ // this.$target could not be in an editable element while the elements
+ // targeted by apply-to are.
+ ($applyTo || this.$target).trigger('content_changed');
+ },
+ /**
+ * Used to handle attribute or data attribute value change
+ *
+ * @param {string} value
+ * @param {Object} params
+ * @returns {string|undefined}
+ */
+ _selectAttributeHelper(value, params) {
+ if (!params.attributeName) {
+ throw new Error('Attribute name missing');
+ }
+ if (params.saveUnit && !params.withUnit) {
+ // Values that come with an unit are saved without unit as
+ // data-attribute unless told otherwise.
+ value = value.split(params.saveUnit).join('');
+ }
+ if (params.extraClass) {
+ this.$target.toggleClass(params.extraClass, params.defaultValue !== value);
+ }
+ return value;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} collapseEl
+ * @param {boolean|undefined} [show]
+ */
+ _toggleCollapseEl(collapseEl, show) {
+ collapseEl.classList.toggle('active', show);
+ collapseEl.querySelector('.o_we_collapse_toggler').classList.toggle('active', show);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onCollapseTogglerClick(ev) {
+ const currentCollapseEl = ev.currentTarget.parentNode;
+ this._toggleCollapseEl(currentCollapseEl);
+ for (const collapseEl of currentCollapseEl.querySelectorAll('we-collapse')) {
+ this._toggleCollapseEl(collapseEl, false);
+ }
+ },
+ /**
+ * Called when a widget notifies a preview/change/reset.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onUserValueUpdate: async function (ev) {
+ ev.stopPropagation();
+ const widget = ev.data.widget;
+ const previewMode = ev.data.previewMode;
+
+ // First check if the updated widget or any of the widgets it triggers
+ // will require a reload or a confirmation choice by the user. If it is
+ // the case, warn the user and potentially ask if he agrees to save its
+ // current changes. If not, just do nothing.
+ let requiresReload = false;
+ if (!ev.data.previewMode && !ev.data.isSimulatedEvent) {
+ const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames);
+ const widgets = [ev.data.widget].concat(linkedWidgets);
+
+ const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets);
+ if (warnMessage) {
+ const okWarning = await new Promise(resolve => {
+ Dialog.confirm(this, warnMessage, {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!okWarning) {
+ return;
+ }
+ }
+
+ const reloadMessage = await this._checkIfWidgetsUpdateNeedReload(widgets);
+ requiresReload = !!reloadMessage;
+ if (requiresReload) {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("This change needs to reload the page, this will save all your changes and reload the page, are you sure you want to proceed?") + ' '
+ + (typeof reloadMessage === 'string' ? reloadMessage : ''), {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!save) {
+ return;
+ }
+ }
+ }
+
+ // Queue action so that we can later skip useless actions.
+ if (!this._actionQueues.get(widget)) {
+ this._actionQueues.set(widget, []);
+ }
+ const currentAction = {previewMode};
+ this._actionQueues.get(widget).push(currentAction);
+
+ // Ask a mutexed snippet update according to the widget value change
+ const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent);
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ // If some previous snippet edition in the mutex removed the target from
+ // the DOM, the widget can be destroyed, in that case the edition request
+ // is now useless and can be discarded.
+ if (this.isDestroyed()) {
+ return;
+ }
+ // Filter actions that are counterbalanced by earlier/later actions
+ const actionQueue = this._actionQueues.get(widget).filter(({previewMode}, i, actions) => {
+ const prev = actions[i - 1];
+ const next = actions[i + 1];
+ if (previewMode === true && next && next.previewMode) {
+ return false;
+ } else if (previewMode === 'reset' && prev && prev.previewMode) {
+ return false;
+ }
+ return true;
+ });
+ // Skip action if it's been counterbalanced
+ if (!actionQueue.includes(currentAction)) {
+ this._actionQueues.set(widget, actionQueue);
+ return;
+ }
+ this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction));
+
+ if (ev.data.prepare) {
+ ev.data.prepare();
+ }
+
+ if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) {
+ // TODO the flag should be fetched through widget params somehow
+ return;
+ }
+
+ // If it is not preview mode, the user selected the option for good
+ // (so record the action)
+ if (shouldRecordUndo) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ }
+
+ // Call widget option methods and update $target
+ await this._select(previewMode, widget);
+ if (previewMode) {
+ return;
+ }
+
+ await new Promise(resolve => setTimeout(() => {
+ // Will update the UI of the correct widgets for all options
+ // related to the same $target/editor
+ this.trigger_up('snippet_option_update', {
+ onSuccess: () => resolve(),
+ });
+ // Set timeout needed so that the user event which triggered the
+ // option can bubble first.
+ }));
+ }});
+
+ if (ev.data.isSimulatedEvent) {
+ // If the user value update was simulated through a trigger, we
+ // prevent triggering further widgets. This could be allowed at some
+ // point but does not work correctly in complex website cases (see
+ // customizeWebsite).
+ return;
+ }
+
+ // Check linked widgets: force their value and simulate a notification
+ const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames);
+ if (linkedWidgets.length !== ev.data.triggerWidgetsNames.length) {
+ console.warn('Missing widget to trigger');
+ return;
+ }
+ let i = 0;
+ const triggerWidgetsValues = ev.data.triggerWidgetsValues;
+ for (const linkedWidget of linkedWidgets) {
+ const widgetValue = triggerWidgetsValues[i];
+ if (widgetValue !== undefined) {
+ // FIXME right now only make this work supposing it is a
+ // colorpicker widget with big big hacks, this should be
+ // improved a lot
+ const normValue = this._normalizeWidgetValue(widgetValue);
+ if (previewMode === true) {
+ linkedWidget._previewColor = normValue;
+ } else if (previewMode === false) {
+ linkedWidget._previewColor = false;
+ linkedWidget._value = normValue;
+ } else {
+ linkedWidget._previewColor = false;
+ }
+ }
+
+ linkedWidget.notifyValueChange(previewMode, true);
+ i++;
+ }
+
+ if (requiresReload) {
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _onUserValueWidgetCritical() {
+ this.trigger_up('remove_snippet', {
+ $snippet: this.$target,
+ });
+ },
+});
+const registry = {};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+registry.sizing = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var def = this._super.apply(this, arguments);
+
+ this.$handles = this.$overlay.find('.o_handle');
+
+ var resizeValues = this._getSize();
+ this.$handles.on('mousedown', function (ev) {
+ ev.preventDefault();
+
+ // First update size values as some element sizes may not have been
+ // initialized on option start (hidden slides, etc)
+ resizeValues = self._getSize();
+ 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 = $(this.ownerDocument.body);
+ $body.addClass(cursor);
+
+ var xy = ev['page' + XY];
+ var bodyMouseMove = 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 bodyMouseUp = function () {
+ $body.off('mousemove', bodyMouseMove);
+ $(window).off('mouseup', bodyMouseUp);
+ $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', bodyMouseMove);
+ $(window).on('mouseup', bodyMouseUp);
+ });
+
+ return def;
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ this._onResize();
+ },
+ /**
+ * @override
+ */
+ onBlur: function () {
+ this.$handles.addClass('readonly');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ setTarget: function () {
+ this._super(...arguments);
+ this._onResize();
+ },
+ /**
+ * @override
+ */
+ updateUI: async function () {
+ await this._super(...arguments);
+ const resizeValues = this._getSize();
+ _.each(resizeValues, (value, key) => {
+ this.$handles.filter('.' + key).toggleClass('readonly', !value);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // 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,
+ });
+ this.$overlay.find('.o_handle.e').css({
+ width: 0,
+ });
+ _.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));
+ });
+ this.$target.trigger('content_changed');
+ },
+});
+
+/**
+ * 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: [grid.map(v => nClass + v), grid, nProp],
+ s: [grid.map(v => sClass + v), grid, sProp],
+ };
+ return this.grid;
+ },
+});
+
+/*
+ * Abstract option to be extended by the ImageOptimize and BackgroundOptimize
+ * options that handles all the common parts.
+ */
+const ImageHandlerOption = SnippetOptionWidget.extend({
+
+ /**
+ * @override
+ */
+ async willStart() {
+ const _super = this._super.bind(this);
+ await this._loadImageInfo();
+ return _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ selectWidth(previewMode, widgetValue, params) {
+ this._getImg().dataset.resizeWidth = widgetValue;
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setQuality(previewMode, widgetValue, params) {
+ this._getImg().dataset.quality = widgetValue;
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ glFilter(previewMode, widgetValue, params) {
+ const dataset = this._getImg().dataset;
+ if (widgetValue) {
+ dataset.glFilter = widgetValue;
+ } else {
+ delete dataset.glFilter;
+ }
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ customFilter(previewMode, widgetValue, params) {
+ const img = this._getImg();
+ const {filterOptions} = img.dataset;
+ const {filterProperty} = params;
+ if (filterProperty === 'filterColor') {
+ widgetValue = normalizeColor(widgetValue);
+ }
+ const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue});
+ img.dataset.filterOptions = JSON.stringify(newOptions);
+ return this._applyOptions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility() {
+ const src = this._getImg().getAttribute('src');
+ return src && src !== '/';
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ const img = this._getImg();
+
+ // Make sure image is loaded because we need its naturalWidth
+ await new Promise((resolve, reject) => {
+ if (img.complete) {
+ resolve();
+ return;
+ }
+ img.addEventListener('load', resolve, {once: true});
+ img.addEventListener('error', resolve, {once: true});
+ });
+
+ switch (methodName) {
+ case 'selectWidth':
+ return img.naturalWidth;
+ case 'setFilter':
+ return img.dataset.filter;
+ case 'glFilter':
+ return img.dataset.glFilter || "";
+ case 'setQuality':
+ return img.dataset.quality || 75;
+ case 'customFilter': {
+ const {filterProperty} = params;
+ const options = JSON.parse(img.dataset.filterOptions || "{}");
+ const defaultValue = filterProperty === 'blend' ? 'normal' : 0;
+ return options[filterProperty] || defaultValue;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async _renderCustomXML(uiFragment) {
+ const isLocalURL = href => new URL(href, window.location.origin).origin === window.location.origin;
+
+ const img = this._getImg();
+ if (!this.originalSrc || !['image/png', 'image/jpeg'].includes(img.dataset.mimetype)) {
+ return [...uiFragment.childNodes].forEach(node => {
+ if (node.matches('.o_we_external_warning')) {
+ node.classList.remove('d-none');
+ if (isLocalURL(img.getAttribute('src'))) {
+ const title = node.querySelector('we-title');
+ title.textContent = ` ${_t("Quality options unavailable")}`;
+ $(title).prepend('<i class="fa fa-warning" />');
+ if (img.dataset.mimetype) {
+ title.setAttribute('title', _t("Only PNG and JPEG images support quality options and image filtering"));
+ } else {
+ title.setAttribute('title', _t("Due to technical limitations, you can only change optimization settings on this image by choosing it again in the media-dialog or reuploading it (double click on the image)"));
+ }
+ }
+ } else {
+ node.remove();
+ }
+ });
+ }
+ const $select = $(uiFragment).find('we-select[data-name=width_select_opt]');
+ (await this._computeAvailableWidths()).forEach(([value, label]) => {
+ $select.append(`<we-button data-select-width="${value}">${label}</we-button>`);
+ });
+
+ if (img.dataset.mimetype !== 'image/jpeg') {
+ uiFragment.querySelector('we-range[data-set-quality]').remove();
+ }
+ },
+ /**
+ * Returns a list of valid widths for a given image.
+ *
+ * @private
+ */
+ async _computeAvailableWidths() {
+ const img = this._getImg();
+ const original = await loadImage(this.originalSrc);
+ const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth;
+ const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth());
+ this.optimizedWidth = optimizedWidth;
+ const widths = {
+ 128: '128px',
+ 256: '256px',
+ 512: '512px',
+ 1024: '1024px',
+ 1920: '1920px',
+ };
+ widths[img.naturalWidth] = _.str.sprintf(_t("%spx"), img.naturalWidth);
+ widths[optimizedWidth] = _.str.sprintf(_t("%dpx (Suggested)"), optimizedWidth);
+ widths[maxWidth] = _.str.sprintf(_t("%dpx (Original)"), maxWidth);
+ return Object.entries(widths)
+ .filter(([width]) => width <= maxWidth)
+ .sort(([v1], [v2]) => v1 - v2);
+ },
+ /**
+ * Applies all selected options on the original image.
+ *
+ * @private
+ */
+ async _applyOptions() {
+ const img = this._getImg();
+ if (!['image/jpeg', 'image/png'].includes(img.dataset.mimetype)) {
+ this.originalId = null;
+ return;
+ }
+ const dataURL = await applyModifications(img);
+ const weight = dataURL.split(',')[1].length / 4 * 3;
+ const $weight = this.$el.find('.o_we_image_weight');
+ $weight.find('> small').text(_t("New size"));
+ $weight.find('b').text(`${(weight / 1024).toFixed(1)} kb`);
+ $weight.removeClass('d-none');
+ img.classList.add('o_modified_image_to_save');
+ const loadedImg = await loadImage(dataURL, img);
+ this._applyImage(loadedImg);
+ return loadedImg;
+ },
+ /**
+ * Loads the image's attachment info.
+ *
+ * @private
+ */
+ async _loadImageInfo() {
+ const img = this._getImg();
+ await loadImageInfo(img, this._rpc.bind(this));
+ if (!img.dataset.originalId) {
+ this.originalId = null;
+ this.originalSrc = null;
+ return;
+ }
+ this.originalId = img.dataset.originalId;
+ this.originalSrc = img.dataset.originalSrc;
+ },
+ /**
+ * Sets the image's width to its suggested size.
+ *
+ * @private
+ */
+ async _autoOptimizeImage() {
+ await this._loadImageInfo();
+ await this._rerenderXML();
+ this._getImg().dataset.resizeWidth = this.optimizedWidth;
+ await this._applyOptions();
+ await this.updateUI();
+ },
+ /**
+ * Returns the image that is currently being modified.
+ *
+ * @private
+ * @abstract
+ * @returns {HTMLImageElement} the image to use for modifications
+ */
+ _getImg() {},
+ /**
+ * Computes the image's maximum display width.
+ *
+ * @private
+ * @abstract
+ * @returns {Int} the maximum width at which the image can be displayed
+ */
+ _computeMaxDisplayWidth() {},
+ /**
+ * Use the processed image when it's needed in the DOM.
+ *
+ * @private
+ * @abstract
+ * @param {HTMLImageElement} img
+ */
+ _applyImage(img) {},
+});
+
+/**
+ * Controls image width and quality.
+ */
+registry.ImageOptimize = ImageHandlerOption.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this));
+ this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.ImageOptimization');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeMaxDisplayWidth() {
+ // TODO: read widths from computed style in case container widths are not default
+ const displayWidth = this._getImg().clientWidth;
+ // If the image is in a column, it might get bigger on smaller screens.
+ // We use col-lg for this in snippets, so they get bigger on the md breakpoint
+ if (this.$target.closest('[class*="col-lg"]').length) {
+ // container and o_container_small have maximum inner width of 690px on the md breakpoint
+ if (this.$target.closest('.container, .o_container_small').length) {
+ return Math.min(1920, Math.max(displayWidth, 690));
+ }
+ // A container-fluid's max inner width is 962px on the md breakpoint
+ return Math.min(1920, Math.max(displayWidth, 962));
+ }
+ // If it's not in a col-lg, it's probably not going to change size depending on breakpoints
+ return displayWidth;
+ },
+ /**
+ * @override
+ */
+ _getImg() {
+ return this.$target[0];
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reloads image data and auto-optimizes the new image.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ async _onImageChanged(ev) {
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ await this._autoOptimizeImage();
+ this.trigger_up('cover_update');
+ }});
+ },
+ /**
+ * Available widths will change, need to rerender the width select.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ async _onImageCropped(ev) {
+ await this._rerenderXML();
+ },
+});
+
+/**
+ * Controls background image width and quality.
+ */
+registry.BackgroundOptimize = ImageHandlerOption.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.BackgroundOptimize');
+ return this._super(...arguments);
+ },
+ /**
+ * Marks the target for creation of an attachment and copies data attributes
+ * to the target so that they can be restored on this.img in later editions.
+ *
+ * @override
+ */
+ async cleanForSave() {
+ const img = this._getImg();
+ if (img.matches('.o_modified_image_to_save')) {
+ this.$target.addClass('o_modified_image_to_save');
+ Object.entries(img.dataset).forEach(([key, value]) => {
+ this.$target[0].dataset[key] = value;
+ });
+ this.$target[0].dataset.bgSrc = img.getAttribute('src');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getImg() {
+ return this.img;
+ },
+ /**
+ * @override
+ */
+ _computeMaxDisplayWidth() {
+ return 1920;
+ },
+ /**
+ * Initializes this.img to an image with the background image url as src.
+ *
+ * @override
+ */
+ async _loadImageInfo() {
+ this.img = new Image();
+ Object.entries(this.$target[0].dataset).filter(([key]) =>
+ // Avoid copying dynamic editor attributes
+ !['oeId','oeModel', 'oeField', 'oeXpath', 'noteId'].includes(key)
+ ).forEach(([key, value]) => {
+ this.img.dataset[key] = value;
+ });
+ const src = getBgImageURL(this.$target[0]);
+ // Don't set the src if not relative (ie, not local image: cannot be modified)
+ this.img.src = src.startsWith('/') ? src : '';
+ return await this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _applyImage(img) {
+ this.$target.css('background-image', `url('${img.getAttribute('src')}')`);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reloads image data when the background is changed.
+ *
+ * @private
+ */
+ async _onBackgroundChanged(ev, previewMode) {
+ if (!previewMode) {
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ await this._autoOptimizeImage();
+ }});
+ }
+ },
+});
+
+registry.BackgroundToggler = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('content_changed.BackgroundToggler', this._onExternalUpdate.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.$target.off('.BackgroundToggler');
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggles background image on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgImage(previewMode, widgetValue, params) {
+ if (!widgetValue) {
+ // TODO: use setWidgetValue instead of calling background directly when possible
+ const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt');
+ const bgImageOpt = bgImageWidget.getParent();
+ return bgImageOpt.background(false, '', bgImageWidget.getMethodsParams('background'));
+ } else {
+ // TODO: use trigger instead of el.click when possible
+ this._requestUserValueWidgets('bg_image_opt')[0].el.click();
+ }
+ },
+ /**
+ * Toggles background shape on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgShape(previewMode, widgetValue, params) {
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const shapeOption = shapeWidget.getParent();
+ // TODO: open select after shape was selected?
+ // TODO: use setWidgetValue instead of calling shapeOption method directly when possible
+ return shapeOption._toggleShape();
+ },
+ /**
+ * Toggles background filter on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgFilter(previewMode, widgetValue, params) {
+ if (widgetValue) {
+ const bgFilterEl = document.createElement('div');
+ bgFilterEl.classList.add('o_we_bg_filter', 'bg-black-50');
+ const lastBackgroundEl = this._getLastPreFilterLayerElement();
+ if (lastBackgroundEl) {
+ $(lastBackgroundEl).after(bgFilterEl);
+ } else {
+ this.$target.prepend(bgFilterEl);
+ }
+ } else {
+ this.$target.find('.o_we_bg_filter').remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'toggleBgImage': {
+ const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt');
+ const bgImageOpt = bgImageWidget.getParent();
+ return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background'));
+ }
+ case 'toggleBgFilter': {
+ return this._hasBgFilter();
+ }
+ case 'toggleBgShape': {
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const shapeOption = shapeWidget.getParent();
+ return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape'));
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _getLastPreFilterLayerElement() {
+ return null;
+ },
+ /**
+ * @private
+ * @returns {Boolean}
+ */
+ _hasBgFilter() {
+ return !!this.$target.find('> .o_we_bg_filter').length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onExternalUpdate() {
+ if (this._hasBgFilter()
+ && !this._getLastPreFilterLayerElement()
+ && !getBgImageURL(this.$target)) {
+ // No 'pre-filter' background layout anymore and no more background
+ // image: remove the background filter option.
+ // TODO there probably is a better system to implement to do that
+ const widget = this._requestUserValueWidgets('bg_filter_toggle_opt')[0];
+ widget.enable();
+ }
+ },
+});
+
+/**
+ * Handles the edition of snippet's background image.
+ */
+registry.BackgroundImage = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles a background change.
+ *
+ * @see this.selectClass for parameters
+ */
+ background: async function (previewMode, widgetValue, params) {
+ if (previewMode === true) {
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ } else if (previewMode === 'reset') {
+ widgetValue = this.__customImageSrc;
+ } else {
+ this.__customImageSrc = widgetValue;
+ }
+
+ this._setBackground(widgetValue);
+
+ if (previewMode !== 'reset') {
+ removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]);
+ this.$target.trigger('background_changed', [previewMode]);
+ }
+ },
+ /**
+ * Changes the main color of dynamic SVGs.
+ *
+ * @see this.selectClass for parameters
+ */
+ async dynamicColor(previewMode, widgetValue, params) {
+ const currentSrc = getBgImageURL(this.$target[0]);
+ switch (previewMode) {
+ case true:
+ this.previousSrc = currentSrc;
+ break;
+ case 'reset':
+ this.$target.css('background-image', `url('${this.previousSrc}')`);
+ return;
+ }
+ const newURL = new URL(currentSrc, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ this.$target.css('background-image', `url('${src}')`);
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ setTarget: function () {
+ // When we change the target of this option we need to transfer the
+ // background-image from the old target to the new one.
+ const oldBgURL = getBgImageURL(this.$target);
+ this._setBackground('');
+ this._super(...arguments);
+ if (oldBgURL) {
+ this._setBackground(oldBgURL);
+ }
+
+ // TODO should be automatic for all options as equal to the start method
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName) {
+ switch (methodName) {
+ case 'background':
+ return getBgImageURL(this.$target[0]);
+ case 'dynamicColor':
+ return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'dynamic_color_opt') {
+ const src = new URL(getBgImageURL(this.$target[0]), window.location.origin);
+ return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {string} backgroundURL
+ */
+ _setBackground(backgroundURL) {
+ if (backgroundURL) {
+ this.$target.css('background-image', `url('${backgroundURL}')`);
+ this.$target.addClass('oe_img_bg');
+ } else {
+ this.$target.css('background-image', '');
+ this.$target.removeClass('oe_img_bg');
+ }
+ },
+});
+
+/**
+ * Handles background shapes.
+ */
+registry.BackgroundShape = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ updateUI() {
+ if (this.rerender) {
+ this.rerender = false;
+ return this._rerenderXML();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the current background shape.
+ *
+ * @see this.selectClass for params
+ */
+ shape(previewMode, widgetValue, params) {
+ this._handlePreviewState(previewMode, () => {
+ return {shape: widgetValue, colors: this._getDefaultColors(), flip: []};
+ });
+ },
+ /**
+ * Sets the current background shape's colors.
+ *
+ * @see this.selectClass for params
+ */
+ color(previewMode, widgetValue, params) {
+ this._handlePreviewState(previewMode, () => {
+ const {colorName} = params;
+ const {colors: previousColors} = this._getShapeData();
+ const newColor = normalizeColor(widgetValue) || this._getDefaultColors()[colorName];
+ const newColors = Object.assign(previousColors, {[colorName]: newColor});
+ return {colors: newColors};
+ });
+ },
+ /**
+ * Flips the shape on its x axis.
+ *
+ * @see this.selectClass for params
+ */
+ flipX(previewMode, widgetValue, params) {
+ this._flipShape(previewMode, 'x');
+ },
+ /**
+ * Flips the shape on its y axis.
+ *
+ * @see this.selectClass for params
+ */
+ flipY(previewMode, widgetValue, params) {
+ this._flipShape(previewMode, 'y');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'shape': {
+ return this._getShapeData().shape;
+ }
+ case 'color': {
+ const {shape, colors: customColors} = this._getShapeData();
+ const colors = Object.assign(this._getDefaultColors(), customColors);
+ const color = shape && colors[params.colorName];
+ return color || '';
+ }
+ case 'flipX': {
+ // Compat: flip classes are no longer used but may be present in client db
+ const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_x').length !== 0;
+ return hasFlipClass || this._getShapeData().flip.includes('x');
+ }
+ case 'flipY': {
+ // Compat: flip classes are no longer used but may be present in client db
+ const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_y').length !== 0;
+ return hasFlipClass || this._getShapeData().flip.includes('y');
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _renderCustomXML(uiFragment) {
+ Object.keys(this._getDefaultColors()).map(colorName => {
+ uiFragment.querySelector('[data-name="colors"]')
+ .prepend($(`<we-colorpicker data-color="true" data-color-name="${colorName}">`)[0]);
+ });
+
+ uiFragment.querySelectorAll('we-select-pager we-button[data-shape]').forEach(btn => {
+ const btnContent = document.createElement('div');
+ btnContent.classList.add('o_we_shape_btn_content', 'position-relative', 'border-dark');
+ const btnContentInnerDiv = document.createElement('div');
+ btnContentInnerDiv.classList.add('o_we_shape');
+ btnContent.appendChild(btnContentInnerDiv);
+
+ const {shape} = btn.dataset;
+ const shapeEl = btnContent.querySelector('.o_we_shape');
+ shapeEl.classList.add(`o_${shape.replace(/\//g, '_')}`);
+ btn.append(btnContent);
+ });
+ return uiFragment;
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'shape_none_opt') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Flips the shape on its x/y axis.
+ *
+ * @param {boolean} previewMode
+ * @param {'x'|'y'} axis the axis of the shape that should be flipped.
+ */
+ _flipShape(previewMode, axis) {
+ this._handlePreviewState(previewMode, () => {
+ const flip = new Set(this._getShapeData().flip);
+ if (flip.has(axis)) {
+ flip.delete(axis);
+ } else {
+ flip.add(axis);
+ }
+ return {flip: [...flip]};
+ });
+ },
+ /**
+ * Handles everything related to saving state before preview and restoring
+ * it after a preview or locking in the changes when not in preview.
+ *
+ * @param {boolean} previewMode
+ * @param {function} computeShapeData function to compute the new shape data.
+ */
+ _handlePreviewState(previewMode, computeShapeData) {
+ const target = this.$target[0];
+ const insertShapeContainer = newContainer => {
+ const shapeContainer = target.querySelector(':scope > .o_we_shape');
+ if (shapeContainer) {
+ shapeContainer.remove();
+ }
+ if (newContainer) {
+ const preShapeLayerElement = this._getLastPreShapeLayerElement();
+ if (preShapeLayerElement) {
+ $(preShapeLayerElement).after(newContainer);
+ } else {
+ this.$target.prepend(newContainer);
+ }
+ }
+ return newContainer;
+ };
+
+ let changedShape = false;
+ if (previewMode === 'reset') {
+ insertShapeContainer(this.prevShapeContainer);
+ if (this.prevShape) {
+ target.dataset.oeShapeData = this.prevShape;
+ } else {
+ delete target.dataset.oeShapeData;
+ }
+ return;
+ } else {
+ if (previewMode === true) {
+ const shapeContainer = target.querySelector(':scope > .o_we_shape');
+ this.prevShapeContainer = shapeContainer && shapeContainer.cloneNode(true);
+ this.prevShape = target.dataset.oeShapeData;
+ }
+ const curShapeData = target.dataset.oeShapeData || {};
+ const newShapeData = computeShapeData();
+ const {shape: curShape} = curShapeData;
+ changedShape = newShapeData.shape !== curShape;
+ this._markShape(newShapeData);
+ if (previewMode === false && changedShape) {
+ // Need to rerender for correct number of colorpickers
+ this.rerender = true;
+ }
+ }
+
+ // Updates/removes the shape container as needed and gives it the
+ // correct background shape
+ const json = target.dataset.oeShapeData;
+ const {shape, colors, flip = []} = json ? JSON.parse(json) : {};
+ let shapeContainer = target.querySelector(':scope > .o_we_shape');
+ if (!shape) {
+ return insertShapeContainer(null);
+ }
+ // When changing shape we want to reset the shape container (for transparency color)
+ if (changedShape) {
+ shapeContainer = insertShapeContainer(null);
+ }
+ if (!shapeContainer) {
+ shapeContainer = insertShapeContainer(document.createElement('div'));
+ target.style.position = 'relative';
+ shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`;
+ }
+ // Compat: remove old flip classes as flipping is now done inside the svg
+ shapeContainer.classList.remove('o_we_flip_x', 'o_we_flip_y');
+
+ if (colors || flip.length) {
+ // Custom colors/flip, overwrite shape that is set by the class
+ $(shapeContainer).css('background-image', `url("${this._getShapeSrc()}")`);
+ shapeContainer.style.backgroundPosition = '';
+ if (flip.length) {
+ let [xPos, yPos] = $(shapeContainer)
+ .css('background-position')
+ .split(' ')
+ .map(p => parseFloat(p));
+ // -X + 2*Y is a symmetry of X around Y, this is a symmetry around 50%
+ xPos = flip.includes('x') ? -xPos + 100 : xPos;
+ yPos = flip.includes('y') ? -yPos + 100 : yPos;
+ shapeContainer.style.backgroundPosition = `${xPos}% ${yPos}%`;
+ }
+ } else {
+ // Remove custom bg image and let the shape class set the bg shape
+ $(shapeContainer).css('background-image', '');
+ $(shapeContainer).css('background-position', '');
+ }
+ if (previewMode === false) {
+ this.prevShapeContainer = shapeContainer.cloneNode(true);
+ this.prevShape = target.dataset.oeShapeData;
+ }
+ },
+ /**
+ * Overwrites shape properties with the specified data.
+ *
+ * @private
+ * @param {Object} newData an object with the new data
+ */
+ _markShape(newData) {
+ const defaultColors = this._getDefaultColors();
+ const shapeData = Object.assign(this._getShapeData(), newData);
+ const areColorsDefault = Object.entries(shapeData.colors).every(([colorName, colorValue]) => {
+ return colorValue.toLowerCase() === defaultColors[colorName].toLowerCase();
+ });
+ if (areColorsDefault) {
+ delete shapeData.colors;
+ }
+ if (!shapeData.shape) {
+ delete this.$target[0].dataset.oeShapeData;
+ } else {
+ this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData);
+ }
+ },
+ /**
+ * @private
+ */
+ _getLastPreShapeLayerElement() {
+ const $filterEl = this.$target.find('> .o_we_bg_filter');
+ if ($filterEl.length) {
+ return $filterEl[0];
+ }
+ return null;
+ },
+ /**
+ * Returns the src of the shape corresponding to the current parameters.
+ *
+ * @private
+ */
+ _getShapeSrc() {
+ const {shape, colors, flip} = this._getShapeData();
+ if (!shape) {
+ return '';
+ }
+ const searchParams = Object.entries(colors)
+ .map(([colorName, colorValue]) => {
+ const encodedCol = encodeURIComponent(colorValue);
+ return `${colorName}=${encodedCol}`;
+ });
+ if (flip.length) {
+ searchParams.push(`flip=${flip.sort().join('')}`);
+ }
+ return `/web_editor/shape/${shape}.svg?${searchParams.join('&')}`;
+ },
+ /**
+ * Retrieves current shape data from the target's dataset.
+ *
+ * @private
+ * @param {HTMLElement} [target=this.$target[0]] the target on which to read
+ * the shape data.
+ */
+ _getShapeData(target = this.$target[0]) {
+ const defaultData = {
+ shape: '',
+ colors: this._getDefaultColors(),
+ flip: [],
+ };
+ const json = target.dataset.oeShapeData;
+ return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData;
+ },
+ /**
+ * Returns the default colors for the currently selected shape.
+ *
+ * @private
+ */
+ _getDefaultColors() {
+ const $shapeContainer = this.$target.find('> .o_we_shape')
+ .clone()
+ .addClass('d-none')
+ // Needs to be in document for bg-image class to take effect
+ .appendTo(document.body);
+ const shapeContainer = $shapeContainer[0];
+ $shapeContainer.css('background-image', '');
+ const shapeSrc = shapeContainer && getBgImageURL(shapeContainer);
+ $shapeContainer.remove();
+ if (!shapeSrc) {
+ return {};
+ }
+ const url = new URL(shapeSrc, window.location.origin);
+ return Object.fromEntries(url.searchParams.entries());
+ },
+ /**
+ * Toggles whether there is a shape or not, to be called from bg toggler.
+ *
+ * @private
+ */
+ _toggleShape() {
+ if (this._getShapeData().shape) {
+ return this._handlePreviewState(false, () => ({shape: ''}));
+ } else {
+ const target = this.$target[0];
+ const previousSibling = target.previousElementSibling;
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues;
+ let shapeToSelect;
+ if (previousSibling) {
+ const previousShape = this._getShapeData(previousSibling).shape;
+ shapeToSelect = possibleShapes.find((shape, i) => {
+ return possibleShapes[i - 1] === previousShape;
+ });
+ } else {
+ shapeToSelect = possibleShapes[1];
+ }
+ return this._handlePreviewState(false, () => ({shape: shapeToSelect}));
+ }
+ },
+});
+
+/**
+ * Handles the edition of snippets' background image position.
+ */
+registry.BackgroundPosition = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._super.apply(this, arguments);
+
+ this._initOverlay();
+
+ // Resize overlay content on window resize because background images
+ // change size, and on carousel slide because they sometimes take up
+ // more space and move elements around them.
+ $(window).on('resize.bgposition', () => this._dimensionOverlay());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._toggleBgOverlay(false);
+ $(window).off('.bgposition');
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the background type (cover/repeat pattern).
+ *
+ * @see this.selectClass for params
+ */
+ backgroundType: function (previewMode, widgetValue, params) {
+ this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern');
+ this.$target.css('background-position', '');
+ this.$target.css('background-size', '');
+ },
+ /**
+ * Saves current background position and enables overlay.
+ *
+ * @see this.selectClass for params
+ */
+ backgroundPositionOverlay: async function (previewMode, widgetValue, params) {
+ // Updates the internal image
+ await new Promise(resolve => {
+ this.img = document.createElement('img');
+ this.img.addEventListener('load', () => resolve());
+ this.img.src = getBgImageURL(this.$target[0]);
+ });
+
+ const position = this.$target.css('background-position').split(' ').map(v => parseInt(v));
+ const delta = this._getBackgroundDelta();
+ // originalPosition kept in % for when movement in one direction doesn't make sense
+ this.originalPosition = {
+ left: position[0],
+ top: position[1],
+ };
+ // Convert % values to pixels for current position because mouse movement is in pixels
+ this.currentPosition = {
+ left: position[0] / 100 * delta.x || 0,
+ top: position[1] / 100 * delta.y || 0,
+ };
+ this._toggleBgOverlay(true);
+ },
+ /**
+ * @override
+ */
+ selectStyle: function (previewMode, widgetValue, params) {
+ if (params.cssProperty === 'background-size'
+ && !this.$target.hasClass('o_bg_img_opt_repeat')) {
+ // Disable the option when the image is in cover mode, otherwise
+ // the background-size: auto style may be forced.
+ return;
+ }
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility: function () {
+ return this._super(...arguments) && !!getBgImageURL(this.$target[0]);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'backgroundType') {
+ return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Initializes the overlay, binds events to the buttons, inserts it in
+ * the DOM.
+ *
+ * @private
+ */
+ _initOverlay: function () {
+ this.$backgroundOverlay = $(qweb.render('web_editor.background_position_overlay'));
+ this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content');
+ this.$overlayBackground = this.$overlayContent.find('.o_overlay_background');
+
+ this.$backgroundOverlay.on('click', '.o_btn_apply', () => {
+ this.$target.css('background-position', this.$bgDragger.css('background-position'));
+ this._toggleBgOverlay(false);
+ });
+ this.$backgroundOverlay.on('click', '.o_btn_discard', () => {
+ this._toggleBgOverlay(false);
+ });
+
+ this.$backgroundOverlay.insertAfter(this.$overlay);
+ },
+ /**
+ * Sets the overlay in the right place so that the draggable background
+ * renders over the target, and size the background item like the target.
+ *
+ * @private
+ */
+ _dimensionOverlay: function () {
+ if (!this.$backgroundOverlay.is('.oe_active')) {
+ return;
+ }
+ // TODO: change #wrapwrap after web_editor rework.
+ const $wrapwrap = $('#wrapwrap');
+ const targetOffset = this.$target.offset();
+
+ this.$backgroundOverlay.css({
+ width: $wrapwrap.innerWidth(),
+ height: $wrapwrap.innerHeight(),
+ });
+
+ this.$overlayContent.offset(targetOffset);
+
+ this.$bgDragger.css({
+ width: `${this.$target.innerWidth()}px`,
+ height: `${this.$target.innerHeight()}px`,
+ });
+
+ const topPos = (parseInt(this.$overlay.css('top')) - parseInt(this.$overlayContent.css('top')));
+ this.$overlayContent.find('.o_we_overlay_buttons').css('top', `${topPos}px`);
+ },
+ /**
+ * Toggles the overlay's display and renders a background clone inside of it.
+ *
+ * @private
+ * @param {boolean} activate toggle the overlay on (true) or off (false)
+ */
+ _toggleBgOverlay: function (activate) {
+ if (!this.$backgroundOverlay || this.$backgroundOverlay.is('.oe_active') === activate) {
+ return;
+ }
+
+ if (!activate) {
+ this.$backgroundOverlay.removeClass('oe_active');
+ this.trigger_up('unblock_preview_overlays');
+ this.trigger_up('activate_snippet', {$snippet: this.$target});
+
+ $(document).off('click.bgposition');
+ return;
+ }
+
+ this.trigger_up('hide_overlay');
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: true,
+ });
+ this.trigger_up('block_preview_overlays');
+
+ // Create empty clone of $target with same display size, make it draggable and give it a tooltip.
+ this.$bgDragger = this.$target.clone().empty();
+ // Prevent clone from being seen as editor if target is editor (eg. background on root tag)
+ this.$bgDragger.removeClass('o_editable');
+ // Some CSS child selector rules will not be applied since the clone has a different container from $target.
+ // The background-attachment property should be the same in both $target & $bgDragger, this will keep the
+ // preview more "wysiwyg" instead of getting different result when bg position saved (e.g. parallax snippet)
+ // TODO: improve this to copy all style from $target and override it with overlay related style (copying all
+ // css into $bgDragger will not work since it will change overlay content style too).
+ this.$bgDragger.css('background-attachment', this.$target.css('background-attachment'));
+ this.$bgDragger.on('mousedown', this._onDragBackgroundStart.bind(this));
+ this.$bgDragger.tooltip({
+ title: 'Click and drag the background to adjust its position!',
+ trigger: 'manual',
+ container: this.$backgroundOverlay
+ });
+
+ // Replace content of overlayBackground, activate the overlay and give it the right dimensions.
+ this.$overlayBackground.empty().append(this.$bgDragger);
+ this.$backgroundOverlay.addClass('oe_active');
+ this._dimensionOverlay();
+ this.$bgDragger.tooltip('show');
+
+ // Needs to be deferred or the click event that activated the overlay deactivates it as well.
+ // This is caused by the click event which we are currently handling bubbling up to the document.
+ window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0);
+ },
+ /**
+ * Returns the difference between the target's size and the background's
+ * rendered size. Background position values in % are a percentage of this.
+ *
+ * @private
+ */
+ _getBackgroundDelta: function () {
+ const bgSize = this.$target.css('background-size');
+ if (bgSize !== 'cover') {
+ let [width, height] = bgSize.split(' ');
+ if (width === 'auto' && (height === 'auto' || !height)) {
+ return {
+ x: this.$target.outerWidth() - this.img.naturalWidth,
+ y: this.$target.outerHeight() - this.img.naturalHeight,
+ };
+ }
+ // At least one of width or height is not auto, so we can use it to calculate the other if it's not set
+ [width, height] = [parseInt(width), parseInt(height)];
+ return {
+ x: this.$target.outerWidth() - (width || (height * this.img.naturalWidth / this.img.naturalHeight)),
+ y: this.$target.outerHeight() - (height || (width * this.img.naturalHeight / this.img.naturalWidth)),
+ };
+ }
+
+ const renderRatio = Math.max(
+ this.$target.outerWidth() / this.img.naturalWidth,
+ this.$target.outerHeight() / this.img.naturalHeight
+ );
+
+ return {
+ x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth),
+ y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight),
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Drags the overlay's background image, copied to target on "Apply".
+ *
+ * @private
+ */
+ _onDragBackgroundStart: function (ev) {
+ ev.preventDefault();
+ this.$bgDragger.addClass('o_we_grabbing');
+ const $document = $(this.ownerDocument);
+ $document.on('mousemove.bgposition', this._onDragBackgroundMove.bind(this));
+ $document.one('mouseup', () => {
+ this.$bgDragger.removeClass('o_we_grabbing');
+ $document.off('mousemove.bgposition');
+ });
+ },
+ /**
+ * Drags the overlay's background image, copied to target on "Apply".
+ *
+ * @private
+ */
+ _onDragBackgroundMove: function (ev) {
+ ev.preventDefault();
+
+ const delta = this._getBackgroundDelta();
+ this.currentPosition.left = clamp(this.currentPosition.left + ev.originalEvent.movementX, [0, delta.x]);
+ this.currentPosition.top = clamp(this.currentPosition.top + ev.originalEvent.movementY, [0, delta.y]);
+
+ const percentPosition = {
+ left: this.currentPosition.left / delta.x * 100,
+ top: this.currentPosition.top / delta.y * 100,
+ };
+ // In cover mode, one delta will be 0 and dividing by it will yield Infinity.
+ // Defaulting to originalPosition in that case (can't be dragged)
+ percentPosition.left = isFinite(percentPosition.left) ? percentPosition.left : this.originalPosition.left;
+ percentPosition.top = isFinite(percentPosition.top) ? percentPosition.top : this.originalPosition.top;
+
+ this.$bgDragger.css('background-position', `${percentPosition.left}% ${percentPosition.top}%`);
+
+ function clamp(val, bounds) {
+ // We sort the bounds because when one dimension of the rendered background is
+ // larger than the container, delta is negative, and we want to use it as lower bound
+ bounds = bounds.sort();
+ return Math.max(bounds[0], Math.min(val, bounds[1]));
+ }
+ },
+ /**
+ * Deactivates the overlay if the user clicks outside of it.
+ *
+ * @private
+ */
+ _onDocumentClicked: function (ev) {
+ if (!$(ev.target).closest('.o_we_background_position_overlay')) {
+ this._toggleBgOverlay(false);
+ }
+ },
+});
+
+/**
+ * Marks color levels of any element that may get or has a color classes. This
+ * is done for the specific main colorpicker option so that those are marked on
+ * snippet drop (so that base snippet definition do not need to care about that)
+ * and on first focus (for compatibility).
+ */
+registry.ColoredLevelBackground = registry.BackgroundToggler.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._markColorLevel();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._markColorLevel();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a specific class indicating the element is colored so that nested
+ * color classes work (we support one-level). Removing it is not useful,
+ * technically the class can be added on anything that *may* receive a color
+ * class: this does not come with any CSS rule.
+ *
+ * @private
+ */
+ _markColorLevel: function () {
+ this.$target.addClass('o_colored_level');
+ },
+});
+
+/**
+ * Allows to replace a text value with the name of a database record.
+ * @todo replace this mechanism with real backend m2o field ?
+ */
+registry.many2one = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.trigger_up('getRecordInfo', _.extend(this.options, {
+ callback: function (recordInfo) {
+ _.defaults(self.options, recordInfo);
+ },
+ }));
+
+ 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'))
+ .prependTo(this.$el);
+
+ 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 () {
+ self.$btn.find('a').on('click', function (e) {
+ self._clear();
+ });
+ }, 0);
+
+ // bind search input
+ this.$search.find('input')
+ .focus()
+ .on('keyup', function (e) {
+ self.$overlay.removeClass('o_overlay_hidden');
+ 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 {Promise}
+ */
+ _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,
+ context: this.options.context,
+ },
+ }).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,
+ context: self.options.context,
+ },
+ }).then(function (html) {
+ $node.html(html);
+ });
+ });
+ } else {
+ self.$target.text($li.data('name'));
+ }
+
+ this._clear();
+ }
+});
+
+/**
+ * Allows to display a warning message on outdated snippets.
+ */
+registry.VersionControl = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.trigger_up('get_snippet_versions', {
+ snippetName: this.$target[0].dataset.snippet,
+ onSuccess: snippetVersions => {
+ const isUpToDate = snippetVersions && ['vjs', 'vcss', 'vxml'].every(key => this.$target[0].dataset[key] === snippetVersions[key]);
+ if (!isUpToDate) {
+ this.$el.prepend(qweb.render('web_editor.outdated_block_message'));
+ }
+ },
+ });
+ return this._super(...arguments);
+ },
+});
+
+/**
+ * Handle the save of a snippet as a template that can be reused later
+ */
+registry.SnippetSave = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+ isTopOption: true,
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ saveSnippet: function (previewMode, widgetValue, params) {
+ return new Promise(resolve => {
+ const dialog = new Dialog(this, {
+ title: _t("Save Your Block"),
+ size: 'small',
+ $content: $(qweb.render('web_editor.dialog.save_snippet', {
+ currentSnippetName: _.str.sprintf(_t("Custom %s"), this.data.snippetName),
+ })),
+ buttons: [{
+ text: _t("Save"),
+ classes: 'btn-primary',
+ close: true,
+ click: async () => {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("To save a snippet, we need to save all your previous modifications and reload the page."), {
+ buttons: [
+ {
+ text: _t("Save and Reload"),
+ classes: 'btn-primary',
+ close: true,
+ click: () => resolve(true),
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ click: () => resolve(false),
+ }
+ ]
+ });
+ });
+ if (!save) {
+ return;
+ }
+ const snippetKey = this.$target[0].dataset.snippet;
+ let thumbnailURL;
+ this.trigger_up('snippet_thumbnail_url_request', {
+ key: snippetKey,
+ onSuccess: url => thumbnailURL = url,
+ });
+ let context;
+ this.trigger_up('context_get', {
+ callback: ctx => context = ctx,
+ });
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ onSuccess: async () => {
+ const snippetName = dialog.el.querySelector('.o_we_snippet_name_input').value;
+ const targetCopyEl = this.$target[0].cloneNode(true);
+ delete targetCopyEl.dataset.name;
+ // By the time onSuccess is called after request_save, the
+ // current widget has been destroyed and is orphaned, so this._rpc
+ // will not work as it can't trigger_up. For this reason, we need
+ // to bypass the service provider and use the global RPC directly
+ await rpc.query({
+ model: 'ir.ui.view',
+ method: 'save_snippet',
+ kwargs: {
+ 'name': snippetName,
+ 'arch': targetCopyEl.outerHTML,
+ 'template_key': this.options.snippets,
+ 'snippet_key': snippetKey,
+ 'thumbnail_url': thumbnailURL,
+ 'context': context,
+ },
+ });
+ },
+ });
+ },
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }],
+ }).open();
+ dialog.on('closed', this, () => resolve());
+ });
+ },
+});
+
+/**
+ * Handles the dynamic colors for dynamic SVGs.
+ */
+registry.DynamicSvg = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.DynamicSvg');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the dynamic SVG's dynamic color.
+ *
+ * @see this.selectClass for params
+ */
+ async color(previewMode, widgetValue, params) {
+ const target = this.$target[0];
+ switch (previewMode) {
+ case true:
+ this.previousSrc = target.getAttribute('src');
+ break;
+ case 'reset':
+ target.src = this.previousSrc;
+ return;
+ }
+ const newURL = new URL(target.src, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ target.src = src;
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'color':
+ return new URL(this.$target[0].src, window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeVisibility(methodName, params) {
+ return this.$target.is("img[src^='/web_editor/shape/']");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onImageChanged(methodName, params) {
+ return this.updateUI();
+ },
+});
+
+return {
+ SnippetOptionWidget: SnippetOptionWidget,
+ snippetOptionRegistry: registry,
+
+ NULL_ID: NULL_ID,
+ UserValueWidget: UserValueWidget,
+ userValueWidgetsRegistry: userValueWidgetsRegistry,
+ UnitUserValueWidget: UnitUserValueWidget,
+
+ addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes,
+ buildElement: _buildElement,
+ buildTitleElement: _buildTitleElement,
+ buildRowElement: _buildRowElement,
+ buildCollapseElement: _buildCollapseElement,
+
+ // Other names for convenience
+ Class: SnippetOptionWidget,
+ 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 00000000..3b49d1d8
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/summernote.js
@@ -0,0 +1,2527 @@
+odoo.define('web_editor.summernote', function (require) {
+'use strict';
+
+var core = require('web.core');
+require('summernote/summernote'); // wait that summernote is loaded
+var weDefaultOptions = require('web_editor.wysiwyg.default_options');
+
+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 foreColorButton = renderer.getTemplate().button(foreColorButtonLabel, {
+ className: 'note-fore-color-preview',
+ title: lang.color.foreground,
+ dropdown: renderer.getTemplate().dropdown('<li><div data-event-name="foreColor" class="colorPalette"/></li>'),
+ });
+ var backColorButton = renderer.getTemplate().button(backColorButtonLabel, {
+ className: 'note-back-color-preview',
+ title: lang.color.background,
+ dropdown: renderer.getTemplate().dropdown('<li><div data-event-name="backColor" class="colorPalette"/></li>'),
+ });
+ return recentColorButton + foreColorButton + backColorButton;
+};
+
+renderer.tplButtonInfo.checklist = function (lang, options) {
+ return '<button ' +
+ 'type="button" ' +
+ 'class="btn btn-secondary btn-sm" ' +
+ 'title="' + _t('Checklist') + '" ' +
+ 'data-event="insertCheckList" ' +
+ 'tabindex="-1" ' +
+ 'data-name="ul" ' +
+ '><i class="fa fa-check-square"></i></button>';
+};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+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 = weDefaultOptions.styleTags;
+
+$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) {
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ 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, type) {
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ type = type || "UL";
+ var sorted = type === "OL";
+
+ 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;
+ if (type !== 'checklist') {
+ ul.classList.remove('o_checklist');
+ } else {
+ ul.classList.add('o_checklist');
+ }
+ parent.insertBefore(ul, node);
+ while (node.firstChild) {
+ ul.appendChild(node.firstChild);
+ }
+ parent.removeChild(node);
+ r.select();
+ return;
+
+ } else if (node.tagName === (sorted ? "OL" : "UL")) {
+
+ if (type === 'checklist' && !node.classList.contains('o_checklist')) {
+ node.classList.add('o_checklist');
+ return;
+ } else if (type === 'UL' && node.classList.contains('o_checklist')) {
+ node.classList.remove('o_checklist');
+ return;
+ }
+
+ 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");
+ if (type === 'checklist') {
+ ul.classList.add('o_checklist');
+ }
+ 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) {
+ $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, "OL");
+};
+$.summernote.pluginEvents.insertCheckList = function (event, editor, layoutInfo) {
+ $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, "checklist");
+ $(range.create().sc.parentNode).trigger('input'); // to update checklist-id
+};
+$.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 previous;
+ var tagName = UL.tagName;
+ var node = UL.firstChild;
+ var ul = document.createElement(tagName);
+ ul.className = UL.className;
+ var li = document.createElement("li");
+ li.classList.add('o_indent');
+ 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;
+ if (previous) {
+ if (dom.isList(previous.lastChild)) {
+ ul = previous.lastChild;
+ } else {
+ previous.appendChild(ul);
+ }
+ } else {
+ node.parentNode.insertBefore(li, node);
+ }
+ }
+ next = dom.nextElementSibling(node);
+ if (flag) {
+ ul.appendChild(node);
+ }
+ if (node === end || $.contains(node, end)) {
+ flag = false;
+ break;
+ }
+ previous = node;
+ 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) {
+ var $succeeding = $(node).nextAll();
+ ul = node.parentNode;
+ if (dom.previousElementSibling(ul)) {
+ dom.insertAfter(node, li);
+ } else {
+ li.parentNode.insertBefore(node, li);
+ }
+ $succeeding.insertAfter(node);
+ if (!ul.children.length) {
+ if (ul.parentNode.tagName === "LI" && !dom.previousElementSibling(ul)) {
+ ul = ul.parentNode;
+ }
+ ul.parentNode.removeChild(ul);
+ }
+ flag = false;
+ break;
+ }
+
+ 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)) {
+ if (dom.isList(ancestor.parentNode)) {
+ $dom = $(ancestor.parentNode);
+ } else {
+ // 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.focus();
+ $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 && r.isOnEditable()) {
+ 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 = weDefaultOptions.fontSizes;
+$.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.childNodes[point.offset] || 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, hasBefore, hasAfter;
+ var noContent = ['none', null, undefined];
+ for (i=0; i<nodes.length; i++) {
+ node = nodes[i];
+
+ if (dom.isText(node) && !node.nodeValue) {
+ remove(node);
+ 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);
+ hasBefore = noContent.indexOf(window.getComputedStyle(node, '::before').content) === -1;
+ hasAfter = noContent.indexOf(window.getComputedStyle(node, '::after').content) === -1;
+
+ if (!className && !style && !hasBefore && !hasAfter) {
+ remove(node, node.parentNode);
+ continue;
+ }
+
+ if (font = dom.ancestor(node.previousSibling, dom.isFont)) {
+ className2 = font.getAttribute('class');
+ style2 = font.getAttribute('style');
+ if (node !== font && className === className2 && style === style2) {
+ remove(node, font);
+ 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, preview) {
+ var $editable = layoutInfo.editable();
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null);
+ if (!preview) {
+ editor.afterCommand($editable);
+ }
+};
+$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor, preview) {
+ 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);
+ if (!preview) {
+ 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);
+ }
+};
+
+function mouseDownChecklist (e) {
+ if (!dom.isLi(e.target) || !$(e.target).parent('ul.o_checklist').length || e.offsetX > 0) {
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ var checked = $(e.target).hasClass('o_checked');
+ $(e.target).toggleClass('o_checked', !checked);
+ var $sublevel = $(e.target).next('ul.o_checklist, li:has(> ul.o_checklist)').find('> li, ul.o_checklist > li');
+ var $parents = $(e.target).parents('ul.o_checklist').map(function () {
+ return this.parentNode.tagName === 'LI' ? this.parentNode : this;
+ });
+ if (checked) {
+ $sublevel.removeClass('o_checked');
+ do {
+ $parents = $parents.prev('ul.o_checklist li').removeClass('o_checked');
+ } while ($parents.length);
+ } else {
+ $sublevel.addClass('o_checked');
+ var $lis;
+ do {
+ $lis = $parents.not(':has(li[id^="checklist-id"]:not(.o_checked))').prev('ul.o_checklist li:not(.o_checked)');
+ $lis.addClass('o_checked');
+ } while ($lis.length);
+ }
+}
+
+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);
+ $editable.on("mousedown", mouseDownChecklist);
+};
+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);
+ $editable.off("mousedown", mouseDownChecklist);
+ $('.o_table_handler').remove();
+};
+
+options.icons.image.image = "file-image-o";
+$.summernote.lang['en-US'].image.image = "File / Image";
+
+return $.summernote;
+});