From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- .../static/src/js/editor/custom_colors.js | 0 addons/web_editor/static/src/js/editor/editor.js | 289 ++ .../static/src/js/editor/image_processing.js | 335 ++ addons/web_editor/static/src/js/editor/rte.js | 816 ++++ .../static/src/js/editor/rte.summernote.js | 1280 +++++ .../static/src/js/editor/snippets.editor.js | 2776 +++++++++++ .../static/src/js/editor/snippets.options.js | 4908 ++++++++++++++++++++ .../web_editor/static/src/js/editor/summernote.js | 2527 ++++++++++ 8 files changed, 12931 insertions(+) create mode 100644 addons/web_editor/static/src/js/editor/custom_colors.js create mode 100644 addons/web_editor/static/src/js/editor/editor.js create mode 100644 addons/web_editor/static/src/js/editor/image_processing.js create mode 100644 addons/web_editor/static/src/js/editor/rte.js create mode 100644 addons/web_editor/static/src/js/editor/rte.summernote.js create mode 100644 addons/web_editor/static/src/js/editor/snippets.editor.js create mode 100644 addons/web_editor/static/src/js/editor/snippets.options.js create mode 100644 addons/web_editor/static/src/js/editor/summernote.js (limited to 'addons/web_editor/static/src/js/editor') 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 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} 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 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 = $('
', {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 = $('
').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 = $('
'); + $padding.insertBefore($imagePopover.find('.btn-group:first')); + var dropdown_content = [ + '
  • '+_t('None')+'
  • ', + '
  • '+_t('Small')+'
  • ', + '
  • '+_t('Medium')+'
  • ', + '
  • '+_t('Large')+'
  • ', + '
  • '+_t('Xl')+'
  • ', + ]; + $(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 = $('
    ').insertAfter($button.parent()); + $(tplIconButton('fa fa-refresh', { + title: _t('Spin'), + event: 'imageShape', + value: 'fa-spin' + })).appendTo($spin); + + // resize for fa + var $resizefa = $('
    ') + .insertAfter($imagePopover.find('.btn-group:has([data-event="resize"])')); + for (var size=1; size<=5; size++) { + $(tplButton(''+size+'x', { + 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 = $('
    '); + $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 = $('
    '); + $alt.appendTo($imagePopover.find('.popover-body')); + $alt.append(''); + + //////////////// 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(/"/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

    ')[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(//); + 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 = $('