diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static/src/js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web_editor/static/src/js')
29 files changed, 18924 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/js/backend/convert_inline.js b/addons/web_editor/static/src/js/backend/convert_inline.js new file mode 100644 index 00000000..071caa3f --- /dev/null +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -0,0 +1,485 @@ +odoo.define('web_editor.convertInline', function (require) { +'use strict'; + +var FieldHtml = require('web_editor.field.html'); + +/** + * Returns the css rules which applies on an element, tweaked so that they are + * browser/mail client ok. + * + * @param {DOMElement} a + * @returns {Object} css property name -> css property value + */ +function getMatchedCSSRules(a) { + var i, r, k; + var doc = a.ownerDocument; + var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); + + if (!rulesCache.length) { + var sheets = doc.styleSheets; + for (i = sheets.length-1 ; i >= 0 ; i--) { + var rules; + // try...catch because browser may not able to enumerate rules for cross-domain sheets + try { + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (rules) { + for (r = rules.length-1; r >= 0; r--) { + var selectorText = rules[r].selectorText; + if (selectorText && + rules[r].cssText && + selectorText !== '*' && + selectorText.indexOf(':hover') === -1 && + selectorText.indexOf(':before') === -1 && + selectorText.indexOf(':after') === -1 && + selectorText.indexOf(':active') === -1 && + selectorText.indexOf(':link') === -1 && + selectorText.indexOf('::') === -1 && + selectorText.indexOf("'") === -1) { + var st = selectorText.split(/\s*,\s*/); + for (k = 0 ; k < st.length ; k++) { + rulesCache.push({ 'selector': st[k], 'style': rules[r].style }); + } + } + } + } + } + rulesCache.reverse(); + } + + var css = []; + var style; + a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector; + for (r = 0; r < rulesCache.length; r++) { + if (a.matches(rulesCache[r].selector)) { + style = rulesCache[r].style; + if (style.parentRule) { + var style_obj = {}; + var len; + for (k = 0, len = style.length ; k < len ; k++) { + if (style[k].indexOf('animation') !== -1) { + continue; + } + style_obj[style[k]] = style[style[k].replace(/-(.)/g, function (a, b) { return b.toUpperCase(); })]; + if (new RegExp(style[k] + '\s*:[^:;]+!important' ).test(style.cssText)) { + style_obj[style[k]] += ' !important'; + } + } + rulesCache[r].style = style = style_obj; + } + css.push([rulesCache[r].selector, style]); + } + } + + function specificity(selector) { + // http://www.w3.org/TR/css3-selectors/#specificity + var a = 0; + selector = selector.replace(/#[a-z0-9_-]+/gi, function () { a++; return ''; }); + var b = 0; + selector = selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ''; }); + var c = 0; + selector = selector.replace(/(^|\s+|:+)[a-z0-9_-]+/gi, function (a) { if (a.indexOf(':not(')===-1) c++; return ''; }); + return a*100 + b*10 + c; + } + css.sort(function (a, b) { return specificity(a[0]) - specificity(b[0]); }); + + style = {}; + _.each(css, function (v,k) { + _.each(v[1], function (v,k) { + if (v && _.isString(v) && k.indexOf('-webkit') === -1 && (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1)) { + style[k] = v; + } + }); + }); + + _.each(style, function (v,k) { + if (v.indexOf('important') !== -1) { + style[k] = v.slice(0, v.length-11); + } + }); + + if (style.display === 'block') { + delete style.display; + } + + // The css generates all the attributes separately and not in simplified form. + // In order to have a better compatibility (outlook for example) we simplify the css tags. + // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none + _.each([ + {property: 'margin'}, + {property: 'padding'}, + {property: 'border', propertyEnd: '-style', defaultValue: 'none'}, + ], function (propertyInfo) { + var p = propertyInfo.property; + var e = propertyInfo.propertyEnd || ''; + var defVal = propertyInfo.defaultValue || 0; + + if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) { + if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) { + // keep => property: [top/right/bottom/left value]; + style[p+e] = style[p+'-top'+e]; + } + else { + // keep => property: [top value] [right value] [bottom value] [left value]; + style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal); + if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) { + // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value]; + delete style[p+e]; + return; + } + } + delete style[p+'-top'+e]; + delete style[p+'-right'+e]; + delete style[p+'-bottom'+e]; + delete style[p+'-left'+e]; + } + }); + + if (style['border-bottom-left-radius']) { + style['border-radius'] = style['border-bottom-left-radius']; + delete style['border-bottom-left-radius']; + delete style['border-bottom-right-radius']; + delete style['border-top-left-radius']; + delete style['border-top-right-radius']; + } + + // if the border styling is initial we remove it to simplify the css tags for compatibility. + // Also, since we do not send a css style tag, the initial value of the border is useless. + _.each(_.keys(style), function (k) { + if (k.indexOf('border') !== -1 && style[k] === 'initial') { + delete style[k]; + } + }); + + // text-decoration rule is decomposed in -line, -color and -style. This is + // however not supported by many browser/mail clients and the editor does + // not allow to change -color and -style rule anyway + if (style['text-decoration-line']) { + style['text-decoration'] = style['text-decoration-line']; + delete style['text-decoration-line']; + delete style['text-decoration-color']; + delete style['text-decoration-style']; + delete style['text-decoration-thickness']; + } + + // text-align inheritance does not seem to get past <td> elements on some + // mail clients + if (style['text-align'] === 'inherit') { + var $el = $(a).parent(); + do { + var align = $el.css('text-align'); + if (_.indexOf(['left', 'right', 'center', 'justify'], align) >= 0) { + style['text-align'] = align; + break; + } + $el = $el.parent(); + } while ($el.length && !$el.is('html')); + } + + return style; +} + +/** + * Converts font icons to images. + * + * @param {jQuery} $editable - the element in which the font icons have to be + * converted to images + */ +function fontToImg($editable) { + var fonts = odoo.__DEBUG__.services["wysiwyg.fonts"]; + + $editable.find('.fa').each(function () { + var $font = $(this); + var icon, content; + _.find(fonts.fontIcons, function (font) { + return _.find(fonts.getCssSelectors(font.parser), function (data) { + if ($font.is(data.selector.replace(/::?before/g, ''))) { + icon = data.names[0].split('-').shift(); + content = data.css.match(/content:\s*['"]?(.)['"]?/)[1]; + return true; + } + }); + }); + if (content) { + var color = $font.css('color').replace(/\s/g, ''); + $font.replaceWith($('<img/>', { + src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, Math.round($font.height()))), + 'data-class': $font.attr('class'), + 'data-style': $font.attr('style'), + class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style + style: $font.attr('style'), + }).css({height: 'auto', width: 'auto'})); + } else { + $font.remove(); + } + }); +} + +/** + * Converts images which were the result of a font icon convertion to a font + * icon again. + * + * @param {jQuery} $editable - the element in which the images will be converted + * back to font icons + */ +function imgToFont($editable) { + $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () { + var $img = $(this); + $img.replaceWith($('<span/>', { + class: $img.data('class'), + style: $img.data('style') + })); + }); +} + +/* + * Utility function to apply function over descendants elements + * + * This is needed until the following issue of jQuery is solved: + * https://github.com./jquery/sizzle/issues/403 + * + * @param {Element} node The root Element node + * @param {Function} func The function applied over descendants + */ +function applyOverDescendants(node, func) { + node = node.firstChild; + while (node) { + if (node.nodeType === 1) { + func(node); + applyOverDescendants(node, func); + } + var $node = $(node); + if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) { + node = $(node).parents('.o_outlook_hack')[0]; + } + else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) { + node = $node.parent()[0]; + } + node = node.nextSibling; + } +} + +/** + * Converts css style to inline style (leave the classes on elements but forces + * the style they give as inline style). + * + * @param {jQuery} $editable + */ +function classToStyle($editable) { + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = $target.attr('style') || ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + if (_.isEmpty(style)) { + $target.removeAttr('style'); + } else { + $target.attr('style', style); + } + // Apple Mail + if (node.nodeName === 'TD' && !node.childNodes.length) { + node.innerHTML = ' '; + } + + // Outlook + if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) { + var $hack = $('<table class="o_outlook_hack" style="display: inline-table;vertical-align:middle"><tr><td></td></tr></table>'); + $hack.find('td') + .attr('height', $target.outerHeight()) + .css({ + 'text-align': $target.parent().css('text-align'), + 'margin': $target.css('padding'), + 'border-radius': $target.css('border-radius'), + 'background-color': $target.css('background-color'), + }); + $target.after($hack); + $target.appendTo($hack.find('td')); + // the space add a line when it's a table but it's invisible when it's a link + node = $hack[0].previousSibling; + if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { + $(node).remove(); + } + node = $hack[0].nextSibling; + if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { + $(node).remove(); + } + } + else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { + $target.wrap('<p class="o_outlook_hack" style="text-align:center;margin:0"/>'); + } + }); +} + +/** + * Removes the inline style which is not necessary (because, for example, a + * class on an element will induce the same style). + * + * @param {jQuery} $editable + */ +function styleToClass($editable) { + // Outlook revert + $editable.find('.o_outlook_hack').each(function () { + $(this).after($('a,img', this)); + }).remove(); + + var $c = $('<span/>').appendTo($editable[0].ownerDocument.body); + + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); + style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';'); + _.each(css, function (v) { + style = style.replace(v, ''); + }); + style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, ''); + if (style !== '') { + $target.attr('style', style); + } else { + $target.removeAttr('style'); + } + }); + $c.remove(); +} + +/** + * Converts css display for attachment link to real image. + * Without this post process, the display depends on the css and the picture + * does not appear when we use the html without css (to send by email for e.g.) + * + * @param {jQuery} $editable + */ +function attachmentThumbnailToLinkImg($editable) { + $editable.find('a[href*="/web/content/"][data-mimetype]').filter(':empty, :containsExact( )').each(function () { + var $link = $(this); + var $img = $('<img/>') + .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, '')) + .css('height', Math.max(1, $link.height()) + 'px') + .css('width', Math.max(1, $link.width()) + 'px'); + $link.prepend($img); + }); +} + +/** + * Revert attachmentThumbnailToLinkImg changes + * + * @see attachmentThumbnailToLinkImg + * @param {jQuery} $editable + */ +function linkImgToAttachmentThumbnail($editable) { + $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove(); +} + + +//-------------------------------------------------------------------------- +//-------------------------------------------------------------------------- + + +FieldHtml.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + commitChanges: function () { + if (this.nodeOptions['style-inline'] && this.mode === "edit") { + this._toInline(); + } + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Converts CSS dependencies to CSS-independent HTML. + * - CSS display for attachment link -> real image + * - Font icons -> images + * - CSS styles -> inline styles + * + * @private + */ + _toInline: function () { + var $editable = this.wysiwyg.getEditable(); + var html = this.wysiwyg.getValue({'style-inline': true}); + $editable.html(html); + + attachmentThumbnailToLinkImg($editable); + fontToImg($editable); + classToStyle($editable); + + // fix outlook image rendering bug + _.each(['width', 'height'], function(attribute) { + $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function(){ + return $(this)[attribute](); + }).css(attribute, function(){ + return $(this).get(0).style[attribute] || 'auto'; + }); + }); + + this.wysiwyg.setValue($editable.html(), { + notifyChange: false, + }); + }, + /** + * Revert _toInline changes. + * + * @private + */ + _fromInline: function () { + var $editable = this.wysiwyg.getEditable(); + var html = this.wysiwyg.getValue(); + $editable.html(html); + + styleToClass($editable); + imgToFont($editable); + linkImgToAttachmentThumbnail($editable); + + // fix outlook image rendering bug + $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width'); + + this.wysiwyg.setValue($editable.html(), { + notifyChange: false, + }); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onLoadWysiwyg: function () { + if (this.nodeOptions['style-inline'] && this.mode === "edit") { + this._fromInline(); + } + this._super(); + }, +}); + +return { + fontToImg: fontToImg, + imgToFont: imgToFont, + classToStyle: classToStyle, + styleToClass: styleToClass, + attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg, + linkImgToAttachmentThumbnail: linkImgToAttachmentThumbnail, +}; +});
\ No newline at end of file diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js new file mode 100644 index 00000000..37f2a8ba --- /dev/null +++ b/addons/web_editor/static/src/js/backend/field_html.js @@ -0,0 +1,536 @@ +odoo.define('web_editor.field.html', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var basic_fields = require('web.basic_fields'); +var config = require('web.config'); +var core = require('web.core'); +var Wysiwyg = require('web_editor.wysiwyg.root'); +var field_registry = require('web.field_registry'); +// must wait for web/ to add the default html widget, otherwise it would override the web_editor one +require('web._field_registry'); + +var _lt = core._lt; +var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin; +var QWeb = core.qweb; +var assetsLoaded; + +var jinjaRegex = /(^|\n)\s*%\s(end|set\s)/; + +/** + * FieldHtml Widget + * Intended to display HTML content. This widget uses the wysiwyg editor + * improved by odoo. + * + * nodeOptions: + * - style-inline => convert class to inline style (no re-edition) => for sending by email + * - no-attachment + * - cssEdit + * - cssReadonly + * - snippets + * - wrapper + */ +var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { + description: _lt("Html"), + className: 'oe_form_field oe_form_field_html', + supportedFieldTypes: ['html'], + + custom_events: { + wysiwyg_focus: '_onWysiwygFocus', + wysiwyg_blur: '_onWysiwygBlur', + wysiwyg_change: '_onChange', + wysiwyg_attachment: '_onAttachmentChange', + }, + + /** + * @override + */ + willStart: function () { + var self = this; + this.isRendered = false; + this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml'); + var defAsset; + if (this.nodeOptions.cssReadonly) { + defAsset = ajax.loadAsset(this.nodeOptions.cssReadonly); + } + + if (!assetsLoaded) { // avoid flickering when begin to edit + assetsLoaded = new Promise(function (resolve) { + var wysiwyg = new Wysiwyg(self, {}); + wysiwyg.attachTo($('<textarea>')).then(function () { + wysiwyg.destroy(); + resolve(); + }); + }); + } + + return Promise.all([this._super(), assetsLoaded, defAsset]); + }, + /** + * @override + */ + destroy: function () { + delete window.top[this._onUpdateIframeId]; + if (this.$iframe) { + this.$iframe.remove(); + } + this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + activate: function (options) { + if (this.wysiwyg) { + this.wysiwyg.focus(); + return true; + } + }, + /** + * Wysiwyg doesn't notify for changes done in code mode. We override + * commitChanges to manually switch back to normal mode before committing + * changes, so that the widget is aware of the changes done in code mode. + * + * @override + */ + commitChanges: function () { + var self = this; + if (config.isDebug() && this.mode === 'edit') { + var layoutInfo = $.summernote.core.dom.makeLayoutInfo(this.wysiwyg.$editor); + $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); + } + if (this.mode == "readonly" || !this.isRendered) { + return this._super(); + } + var _super = this._super.bind(this); + return this.wysiwyg.saveModifiedImages(this.$content).then(function () { + return self.wysiwyg.save(self.nodeOptions).then(function (result) { + self._isDirty = result.isDirty; + _super(); + }); + }); + }, + /** + * @override + */ + isSet: function () { + var value = this.value && this.value.split(' ').join('').replace(/\s/g, ''); // Removing spaces & html spaces + return value && value !== "<p></p>" && value !== "<p><br></p>" && value.match(/\S/); + }, + /** + * @override + */ + getFocusableElement: function () { + return this.$target || $(); + }, + /** + * Do not re-render this field if it was the origin of the onchange call. + * + * @override + */ + reset: function (record, event) { + this._reset(record, event); + var value = this.value; + if (this.nodeOptions.wrapper) { + value = this._wrap(value); + } + value = this._textToHtml(value); + if (!event || event.target !== this) { + if (this.mode === 'edit') { + this.wysiwyg.setValue(value); + } else { + this.$content.html(value); + } + } + return Promise.resolve(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getValue: function () { + var value = this.$target.val(); + if (this.nodeOptions.wrapper) { + return this._unWrap(value); + } + return value; + }, + /** + * Create the wysiwyg instance with the target (this.$target) + * then add the editable content (this.$content). + * + * @private + * @returns {$.Promise} + */ + _createWysiwygIntance: function () { + var self = this; + this.wysiwyg = new Wysiwyg(this, this._getWysiwygOptions()); + this.wysiwyg.__extraAssetsForIframe = this.__extraAssetsForIframe || []; + + // by default this is synchronous because the assets are already loaded in willStart + // but it can be async in the case of options such as iframe, snippets... + return this.wysiwyg.attachTo(this.$target).then(function () { + self.$content = self.wysiwyg.$editor.closest('body, odoo-wysiwyg-container'); + self._onLoadWysiwyg(); + self.isRendered = true; + }); + }, + /** + * Get wysiwyg options to create wysiwyg instance. + * + * @private + * @returns {Object} + */ + _getWysiwygOptions: function () { + var self = this; + return Object.assign({}, this.nodeOptions, { + recordInfo: { + context: this.record.getContext(this.recordParams), + res_model: this.model, + res_id: this.res_id, + }, + noAttachment: this.nodeOptions['no-attachment'], + inIframe: !!this.nodeOptions.cssEdit, + iframeCssAssets: this.nodeOptions.cssEdit, + snippets: this.nodeOptions.snippets, + + tabsize: 0, + height: 180, + generateOptions: function (options) { + var toolbar = options.toolbar || options.airPopover || {}; + var para = _.find(toolbar, function (item) { + return item[0] === 'para'; + }); + if (para && para[1] && para[1].indexOf('checklist') === -1) { + para[1].splice(2, 0, 'checklist'); + } + if (config.isDebug()) { + options.codeview = true; + var view = _.find(toolbar, function (item) { + return item[0] === 'view'; + }); + if (view) { + if (!view[1].includes('codeview')) { + view[1].splice(-1, 0, 'codeview'); + } + } else { + toolbar.splice(-1, 0, ['view', ['codeview']]); + } + } + if (self.model === "mail.compose.message" || self.model === "mailing.mailing") { + options.noVideos = true; + } + options.prettifyHtml = false; + return options; + }, + }); + }, + /** + * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view. + * This method is called when an image is uploaded via the media dialog. + * + * For e.g. when sending email, this allows people to add attachments with the content + * editor interface and that they appear in the attachment list. + * The new documents being attached to the email, they will not be erased by the CRON + * when closing the wizard. + * + * @private + * @param {Object} attachments + */ + _onAttachmentChange: function (attachments) { + if (!this.fieldNameAttachment) { + return; + } + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: _.object([this.fieldNameAttachment], [{ + operation: 'ADD_M2M', + ids: attachments + }]) + }); + }, + /** + * @override + */ + _renderEdit: function () { + var value = this._textToHtml(this.value); + if (this.nodeOptions.wrapper) { + value = this._wrap(value); + } + this.$target = $('<textarea>').val(value).hide(); + this.$target.appendTo(this.$el); + + var fieldNameAttachment = _.chain(this.recordData) + .pairs() + .find(function (value) { + return _.isObject(value[1]) && value[1].model === "ir.attachment"; + }) + .first() + .value(); + if (fieldNameAttachment) { + this.fieldNameAttachment = fieldNameAttachment; + } + + if (this.nodeOptions.cssEdit) { + // must be async because the target must be append in the DOM + this._createWysiwygIntance(); + } else { + return this._createWysiwygIntance(); + } + }, + /** + * @override + */ + _renderReadonly: function () { + var self = this; + var value = this._textToHtml(this.value); + if (this.nodeOptions.wrapper) { + value = this._wrap(value); + } + + this.$el.empty(); + var resolver; + var def = new Promise(function (resolve) { + resolver = resolve; + }); + if (this.nodeOptions.cssReadonly) { + this.$iframe = $('<iframe class="o_readonly"/>'); + this.$iframe.appendTo(this.$el); + + var avoidDoubleLoad = 0; // this bug only appears on some computers with some chrome version. + + // inject content in iframe + + this.$iframe.data('loadDef', def); // for unit test + window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg iframe double load detected'); + return; + } + self.$content = $('#iframe_target', self.$iframe[0].contentWindow.document.body); + resolver(); + }; + + this.$iframe.on('load', function onLoad() { + var _avoidDoubleLoad = ++avoidDoubleLoad; + ajax.loadAsset(self.nodeOptions.cssReadonly).then(function (asset) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg immediate iframe double load detected'); + return; + } + var cwindow = self.$iframe[0].contentWindow; + cwindow.document + .open("text/html", "replace") + .write( + '<head>' + + '<meta charset="utf-8"/>' + + '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' + + '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' + + _.map(asset.cssLibs, function (cssLib) { + return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>'; + }).join('\n') + '\n' + + _.map(asset.cssContents, function (cssContent) { + return '<style type="text/css">' + cssContent + '</style>'; + }).join('\n') + '\n' + + '</head>\n' + + '<body class="o_in_iframe o_readonly">\n' + + '<div id="iframe_target">' + value + '</div>\n' + + '<script type="text/javascript">' + + 'if (window.top.' + self._onUpdateIframeId + ') {' + + 'window.top.' + self._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' + + '}' + + '</script>\n' + + '</body>'); + + var height = cwindow.document.body.scrollHeight; + self.$iframe.css('height', Math.max(30, Math.min(height, 500)) + 'px'); + }); + }); + } else { + this.$content = $('<div class="o_readonly"/>').html(value); + this.$content.appendTo(this.$el); + resolver(); + } + + def.then(function () { + self.$content.on('click', 'ul.o_checklist > li', self._onReadonlyClickChecklist.bind(self)); + }); + }, + /** + * @private + * @param {string} text + * @returns {string} the text converted to html + */ + _textToHtml: function (text) { + var value = text || ""; + if (jinjaRegex.test(value)) { // is jinja + return value; + } + try { + $(text)[0].innerHTML; // crashes if text isn't html + } catch (e) { + if (value.match(/^\s*$/)) { + value = '<p><br/></p>'; + } else { + value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>"; + value = value + .replace(/<p><\/p>/g, '') + .replace('<p><p>', '<p>') + .replace('<p><p ', '<p ') + .replace('</p></p>', '</p>'); + } + } + return value; + }, + /** + * Move HTML contents out of their wrapper. + * + * @private + * @param {string} html content + * @returns {string} html content + */ + _unWrap: function (html) { + var $wrapper = $(html).find('#wrapper'); + return $wrapper.length ? $wrapper.html() : html; + }, + /** + * Wrap HTML in order to create a custom display. + * + * The wrapper (this.nodeOptions.wrapper) must be a static + * XML template with content id="wrapper". + * + * @private + * @param {string} html content + * @returns {string} html content + */ + _wrap: function (html) { + return $(QWeb.render(this.nodeOptions.wrapper)) + .find('#wrapper').html(html) + .end().prop('outerHTML'); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * Method called when wysiwyg triggers a change. + * + * @private + * @param {OdooEvent} ev + */ + _onChange: function (ev) { + this._doDebouncedAction.apply(this, arguments); + + var $lis = this.$content.find('.note-editable ul.o_checklist > li:not(:has(> ul.o_checklist))'); + if (!$lis.length) { + return; + } + var max = 0; + var ids = []; + $lis.map(function () { + var checklistId = parseInt(($(this).attr('id') || '0').replace(/^checklist-id-/, '')); + if (ids.indexOf(checklistId) === -1) { + if (checklistId > max) { + max = checklistId; + } + ids.push(checklistId); + } else { + $(this).removeAttr('id'); + } + }); + $lis.not('[id]').each(function () { + $(this).attr('id', 'checklist-id-' + (++max)); + }); + }, + /** + * Allows Enter keypress in a textarea (source mode) + * + * @private + * @param {OdooEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ENTER) { + ev.stopPropagation(); + return; + } + this._super.apply(this, arguments); + }, + /** + * Method called when wysiwyg triggers a change. + * + * @private + * @param {OdooEvent} ev + */ + _onReadonlyClickChecklist: function (ev) { + var self = this; + if (ev.offsetX > 0) { + return; + } + ev.stopPropagation(); + ev.preventDefault(); + var checked = $(ev.target).hasClass('o_checked'); + var checklistId = parseInt(($(ev.target).attr('id') || '0').replace(/^checklist-id-/, '')); + + this._rpc({ + route: '/web_editor/checklist', + params: { + res_model: this.model, + res_id: this.res_id, + filename: this.name, + checklistId: checklistId, + checked: !checked, + }, + }).then(function (value) { + self._setValue(value); + }); + }, + /** + * Method called when the wysiwyg instance is loaded. + * + * @private + */ + _onLoadWysiwyg: function () { + var $button = this._renderTranslateButton(); + $button.css({ + 'font-size': '15px', + position: 'absolute', + right: '+5px', + top: '+5px', + }); + this.$el.append($button); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onWysiwygBlur: function (ev) { + ev.stopPropagation(); + this._doAction(); + if (ev.data.key === 'TAB') { + this.trigger_up('navigation_move', { + direction: ev.data.shiftKey ? 'left' : 'right', + }); + } + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onWysiwygFocus: function (ev) {}, +}); + + +field_registry.add('html', FieldHtml); + + +return FieldHtml; +}); diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js new file mode 100644 index 00000000..7d807114 --- /dev/null +++ b/addons/web_editor/static/src/js/base.js @@ -0,0 +1,173 @@ +odoo.define('web_editor.base', function (require) { +'use strict'; + +// TODO this should be re-removed as soon as possible. + +var ajax = require('web.ajax'); +var session = require('web.session'); + +var domReady = new Promise(function(resolve) { + $(resolve); +}); + +return { + /** + * Retrieves all the CSS rules which match the given parser (Regex). + * + * @param {Regex} filter + * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is + * defined by 3 values: 'selector', 'css' and 'names'. 'selector' + * is a string which contains the whole selector, 'css' is a string + * which contains the css properties and 'names' is an array of the + * first captured groups for each selector part. E.g.: if the + * filter is set to match .fa-* rules and capture the icon names, + * the rule: + * '.fa-alias1::before, .fa-alias2::before { hello: world; }' + * will be retrieved as + * { + * selector: '.fa-alias1::before, .fa-alias2::before', + * css: 'hello: world;', + * names: ['.fa-alias1', '.fa-alias2'], + * } + */ + cacheCssSelectors: {}, + getCssSelectors: function (filter) { + if (this.cacheCssSelectors[filter]) { + return this.cacheCssSelectors[filter]; + } + this.cacheCssSelectors[filter] = []; + var sheets = document.styleSheets; + for (var i = 0; i < sheets.length; i++) { + var rules; + try { + // try...catch because Firefox not able to enumerate + // document.styleSheets[].cssRules[] for cross-domain + // stylesheets. + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (!rules) { + continue; + } + + for (var r = 0 ; r < rules.length ; r++) { + var selectorText = rules[r].selectorText; + if (!selectorText) { + continue; + } + var selectors = selectorText.split(/\s*,\s*/); + var data = null; + for (var s = 0; s < selectors.length; s++) { + var match = selectors[s].trim().match(filter); + if (!match) { + continue; + } + if (!data) { + data = { + selector: match[0], + css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''), + names: [match[1]] + }; + } else { + data.selector += (', ' + match[0]); + data.names.push(match[1]); + } + } + if (data) { + this.cacheCssSelectors[filter].push(data); + } + } + } + return this.cacheCssSelectors[filter]; + }, + /** + * List of font icons to load by editor. The icons are displayed in the media + * editor and identified like font and image (can be colored, spinned, resized + * with fa classes). + * To add font, push a new object {base, parser} + * + * - base: class who appear on all fonts + * - parser: regular expression used to select all font in css stylesheets + * + * @type Array + */ + fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}], + /** + * Searches the fonts described by the @see fontIcons variable. + */ + computeFonts: _.once(function () { + var self = this; + _.each(this.fontIcons, function (data) { + data.cssData = self.getCssSelectors(data.parser); + data.alias = _.flatten(_.map(data.cssData, _.property('names'))); + }); + }), + /** + * If a widget needs to be instantiated on page loading, it needs to wait + * for appropriate resources to be loaded. This function returns a Promise + * which is resolved when the dom is ready, the session is bound + * (translations loaded) and the XML is loaded. This should however not be + * necessary anymore as widgets should not be parentless and should then be + * instantiated (directly or not) by the page main component (webclient, + * website root, editor bar, ...). The DOM will be ready then, the main + * component is in charge of waiting for the session and the XML can be + * lazy loaded thanks to the @see Widget.xmlDependencies key. + * + * @returns {Promise} + */ + ready: function () { + return Promise.all([domReady, session.is_bound, ajax.loadXML()]); + }, +}; +}); + +//============================================================================== + +odoo.define('web_editor.context', function (require) { +'use strict'; + +// TODO this should be re-removed as soon as possible. + +function getContext(context) { + var html = document.documentElement; + return _.extend({ + lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'), + + // Unfortunately this is a mention of 'website' in 'web_editor' as there + // was no other way to do it as this was restored in a stable version. + // Indeed, the editor is currently using this context at the root of JS + // module, so there is no way for website to hook itself before + // web_editor uses it (without a risky refactoring of web_editor in + // stable). As mentioned above, the editor should not use this context + // anymore anyway (this was restored by the saas-12.2 editor revert). + 'website_id': html.getAttribute('data-website-id') | 0, + }, context || {}); +} +function getExtraContext(context) { + var html = document.documentElement; + return _.extend(getContext(), { + editable: !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python + translatable: !!html.dataset.translatable, + edit_translations: !!html.dataset.edit_translations, + }, context || {}); +} + +return { + get: getContext, + getExtra: getExtraContext, +}; +}); + +//============================================================================== + +odoo.define('web_editor.ready', function (require) { +'use strict'; + +// TODO this should be re-removed as soon as possible. + +var base = require('web_editor.base'); + +return base.ready(); +}); diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js new file mode 100644 index 00000000..0c3198de --- /dev/null +++ b/addons/web_editor/static/src/js/common/ace.js @@ -0,0 +1,944 @@ +odoo.define('web_editor.ace', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var config = require('web.config'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var dom = require('web.dom'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var localStorage = require('web.local_storage'); + +var _t = core._t; + +/** + * Formats a content-check result (@see checkXML, checkSCSS). + * + * @param {boolean} isValid + * @param {integer} [errorLine] needed if isValid is false + * @param {string} [errorMessage] needed if isValid is false + * @returns {Object} + */ +function _getCheckReturn(isValid, errorLine, errorMessage) { + return { + isValid: isValid, + error: isValid ? null : { + line: errorLine, + message: errorMessage, + }, + }; +} +/** + * Checks the syntax validity of some XML. + * + * @param {string} xml + * @returns {Object} @see _getCheckReturn + */ +function checkXML(xml) { + if (typeof window.DOMParser != 'undefined') { + var xmlDoc = (new window.DOMParser()).parseFromString(xml, 'text/xml'); + var error = xmlDoc.getElementsByTagName('parsererror'); + if (error.length > 0) { + return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), error[0].innerHTML); + } + } else if (typeof window.ActiveXObject != 'undefined' && new window.ActiveXObject('Microsoft.XMLDOM')) { + var xmlDocIE = new window.ActiveXObject('Microsoft.XMLDOM'); + xmlDocIE.async = 'false'; + xmlDocIE.loadXML(xml); + if (xmlDocIE.parseError.line > 0) { + return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason); + } + } + return _getCheckReturn(true); +} +/** + * Formats some XML so that it has proper indentation and structure. + * + * @param {string} xml + * @returns {string} formatted xml + */ +function formatXML(xml) { + // do nothing if an inline script is present to avoid breaking it + if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) { + return xml; + } + return window.vkbeautify.xml(xml, 4); +} +/** + * Checks the syntax validity of some SCSS. + * + * @param {string} scss + * @returns {Object} @see _getCheckReturn + */ +var checkSCSS = (function () { + var mapping = { + '{': '}', '}': '{', + '(': ')', ')': '(', + '[': ']', ']': '[', + }; + var openings = ['{', '(', '[']; + var closings = ['}', ')', ']']; + + return function (scss) { + var stack = []; + var line = 1; + for (var i = 0 ; i < scss.length ; i++) { + if (_.contains(openings, scss[i])) { + stack.push(scss[i]); + } else if (_.contains(closings, scss[i])) { + if (stack.pop() !== mapping[scss[i]]) { + return _getCheckReturn(false, line, _t("Unexpected ") + scss[i]); + } + } else if (scss[i] === '\n') { + line++; + } + } + if (stack.length > 0) { + return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]); + } + return _getCheckReturn(true); + }; +})(); +/** + * Formats some SCSS so that it has proper indentation and structure. + * + * @todo Right now, this does return the given SCSS content, untouched. + * @param {string} scss + * @returns {string} formatted scss + */ +function formatSCSS(scss) { + return scss; +} + +/** + * Allows to visualize resources (by default, XML views) and edit them. + */ +var ViewEditor = Widget.extend({ + template: 'web_editor.ace_view_editor', + xmlDependencies: ['/web_editor/static/src/xml/ace.xml'], + jsLibs: [ + '/web/static/lib/ace/ace.js', + [ + '/web/static/lib/ace/javascript_highlight_rules.js', + '/web/static/lib/ace/mode-xml.js', + '/web/static/lib/ace/mode-scss.js', + '/web/static/lib/ace/mode-js.js', + '/web/static/lib/ace/theme-monokai.js' + ] + ], + events: { + 'click .o_ace_type_switcher_choice': '_onTypeChoice', + 'change .o_res_list': '_onResChange', + 'click .o_ace_filter': '_onFilterChange', + 'click button[data-action=save]': '_onSaveClick', + 'click button[data-action=reset]': '_onResetClick', + 'click button[data-action=format]': '_onFormatClick', + 'click button[data-action=close]': '_onCloseClick', + 'click #ace-view-id > .alert-warning .close': '_onCloseWarningClick' + }, + + /** + * Initializes the parameters so that the ace editor knows which information + * it has to load. + * + * @constructor + * @param {Widget} parent + * @param {string|integer} viewKey + * xml_id or id of the view whose linked resources have to be loaded. + * @param {Object} [options] + * @param {string|integer} [options.initialResID] + * a specific view ID / SCSS URL to load on start (otherwise the main + * view ID associated with the specified viewKey will be used) + * @param {string} [options.position=right] + * @param {boolean} [options.doNotLoadViews=false] + * @param {boolean} [options.doNotLoadSCSS=false] + * @param {boolean} [options.doNotLoadJS=false] + * @param {boolean} [options.includeBundles=false] + * @param {string} [options.filesFilter=custom] + * @param {string[]} [options.defaultBundlesRestriction] + */ + init: function (parent, viewKey, options) { + this._super.apply(this, arguments); + + this.context = options.context; + + this.viewKey = viewKey; + this.options = _.defaults({}, options, { + position: 'right', + doNotLoadViews: false, + doNotLoadSCSS: false, + doNotLoadJS: false, + includeBundles: false, + filesFilter: 'custom', + defaultBundlesRestriction: [], + }); + + this.resources = {xml: {}, scss: {}, js: {}}; + this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.currentType = 'xml'; + + // Alias + this.views = this.resources.xml; + this.scss = this.resources.scss; + this.js = this.resources.js; + }, + /** + * Loads everything the ace library needs to work. + * It also loads the resources to visualize (@see _loadResources). + * + * @override + */ + willStart: function () { + return Promise.all([ + this._super.apply(this, arguments), + this._loadResources() + ]); + }, + /** + * Initializes the library and initial view once the DOM is ready. It also + * initializes the resize feature of the ace editor. + * + * @override + */ + start: function () { + this.$viewEditor = this.$('#ace-view-editor'); + + this.$typeSwitcherChoices = this.$('.o_ace_type_switcher_choice'); + this.$typeSwitcherBtn = this.$('.o_ace_type_switcher > .dropdown-toggle'); + + this.$lists = { + xml: this.$('#ace-view-list'), + scss: this.$('#ace-scss-list'), + js: this.$('#ace-js-list'), + }; + this.$includeBundlesArea = this.$('.oe_include_bundles'); + this.$includeAllSCSSArea = this.$('.o_include_all_scss'); + this.$viewID = this.$('#ace-view-id > span'); + this.$warningMessage = this.$('#ace-view-id > .alert-warning'); + + this.$formatButton = this.$('button[data-action=format]'); + this.$resetButton = this.$('button[data-action=reset]'); + + this.aceEditor = window.ace.edit(this.$viewEditor[0]); + this.aceEditor.setTheme('ace/theme/monokai'); + this.$editor = this.$('.ace_editor'); + + var refX = 0; + var resizing = false; + var minWidth = 400; + var debounceStoreEditorWidth = _.debounce(storeEditorWidth, 500); + + this._updateViewSelectDOM(); + + var initResID; + var initType; + if (this.options.initialResID) { + initResID = this.options.initialResID; + if (_.isString(initResID) && initResID[0] === '/') { + if (_.str.endsWith(initResID, '.scss')) { + initType = 'scss'; + } else { + initType = 'js'; + } + } else { + initType = 'xml'; + } + } else { + if (!this.options.doNotLoadSCSS) { + initResID = this.sortedSCSS[0][1][0].url; // first bundle, scss files, first one + initType = 'scss'; + } + if (!this.options.doNotLoadJS) { + initResID = this.sortedJS[0][1][0].url; // first bundle, js files, first one + initType = 'js'; + } + if (!this.options.doNotLoadViews) { + if (typeof this.viewKey === "number") { + initResID = this.viewKey; + } else { + var view = _.findWhere(this.views, {xml_id: this.viewKey}); + if (!view) { + view = _.findWhere(this.views, {key: this.viewKey}); + } + initResID = view.id; + } + initType = 'xml'; + } + } + if (initResID) { + this._displayResource(initResID, initType); + } + + if (!this.sortedViews.length || !this.sortedSCSS.length) { + _.defer((function () { + this._switchType(this.sortedViews.length ? 'xml' : 'scss'); + this.$typeSwitcherBtn.parent('.btn-group').addClass('d-none'); + }).bind(this)); + } + + $(document).on('mouseup.ViewEditor', stopResizing.bind(this)).on('mousemove.ViewEditor', updateWidth.bind(this)); + if (this.options.position === 'left') { + this.$('.ace_scroller').after($('<div>').addClass('ace_resize_bar')); + this.$('.ace_gutter').css({'cursor': 'default'}); + this.$el.on('mousedown.ViewEditor', '.ace_resize_bar', startResizing.bind(this)); + } else { + this.$el.on('mousedown.ViewEditor', '.ace_gutter', startResizing.bind(this)); + } + + resizeEditor.call(this, readEditorWidth.call(this)); + + return this._super.apply(this, arguments); + + function resizeEditor(target) { + var width = Math.min(document.body.clientWidth, Math.max(parseInt(target, 10), minWidth)); + this.$editor.width(width); + this.aceEditor.resize(); + this.$el.width(width); + } + function storeEditorWidth() { + localStorage.setItem('ace_editor_width', this.$el.width()); + } + function readEditorWidth() { + var width = localStorage.getItem('ace_editor_width'); + return parseInt(width || 720, 10); + } + function startResizing(e) { + refX = e.pageX; + resizing = true; + } + function stopResizing() { + resizing = false; + } + function updateWidth(e) { + if (!resizing) return; + + var offset = e.pageX - refX; + if (this.options.position === 'left') { + offset = - offset; + } + var width = this.$el.width() - offset; + refX = e.pageX; + resizeEditor.call(this, width); + debounceStoreEditorWidth.call(this); + } + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$el.off('.ViewEditor'); + $(document).off('.ViewEditor'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Initializes a text editor for the specified resource. + * + * @private + * @param {integer|string} resID - the ID/URL of the view/scss/js file + * @param {string} [type] (default to the currently selected one) + * @returns {ace.EditSession} + */ + _buildEditingSession: function (resID, type) { + var self = this; + type = type || this.currentType; + var editingSession = new window.ace.EditSession(this.resources[type][resID].arch); + editingSession.setUseWorker(false); + editingSession.setMode('ace/mode/' + (type || this.currentType)); + editingSession.setUndoManager(new window.ace.UndoManager()); + editingSession.on('change', function () { + _.defer(function () { + self._toggleDirtyInfo(resID); + self._showErrorLine(); + }); + }); + return editingSession; + }, + /** + * Forces the view/scss/js file identified by its ID/URL to be displayed in the + * editor. The method will update the resource select DOM element as well if + * necessary. + * + * @private + * @param {integer|string} resID + * @param {string} [type] - the type of resource (either 'xml', 'scss' or 'js') + */ + _displayResource: function (resID, type) { + if (type) { + this._switchType(type); + } + + var editingSession = this.editingSessions[this.currentType][resID]; + if (!editingSession) { + editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID); + } + this.aceEditor.setSession(editingSession); + + if (this.currentType === 'xml') { + this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key)); + } else if (this.currentType === 'scss') { + this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); + } else { + this.$viewID.text(_.str.sprintf(_t("JS file: %s"), resID)); + } + const isCustomized = this._isCustomResource(resID); + this.$lists[this.currentType].select2('val', resID); + + this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized); + + this.$warningMessage.toggleClass('d-none', + this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0) || isCustomized); + + this.aceEditor.resize(true); + }, + /** + * Formats the current resource being vizualized. + * (@see formatXML, formatSCSS) + * + * @private + */ + _formatResource: function () { + var res = this.aceEditor.getValue(); + var check = (this.currentType === 'xml' ? checkXML : checkSCSS)(res); + if (check.isValid) { + this.aceEditor.setValue((this.currentType === 'xml' ? formatXML : formatSCSS)(res)); + } else { + this._showErrorLine(check.error.line, check.error.message, this._getSelectedResource()); + } + }, + /** + * Returns the currently selected resource data. + * + * @private + * @returns {integer|string} view ID or scss file URL + */ + _getSelectedResource: function () { + var value = this.$lists[this.currentType].select2('val'); + return parseInt(value, 10) || value; + }, + /** + * Checks resource is customized or not. + * + * @private + * @param {integer|string} resID + */ + _isCustomResource(resID) { + // TODO we should be able to detect if the XML template is customized + // to not show the warning in that case + let isCustomized = false; + if (this.currentType === 'scss') { + isCustomized = this.scss[resID].customized; + } else if (this.currentType === 'js') { + isCustomized = this.js[resID].customized; + } + return isCustomized; + }, + /** + * Loads data the ace editor will vizualize and process it. Default behavior + * is loading the activate views, index them and build their hierarchy. + * + * @private + * @returns {Promise} + */ + _loadResources: function () { + // Reset resources + this.resources = {xml: {}, scss: {}, js: {}}; + this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.views = this.resources.xml; + this.scss = this.resources.scss; + this.js = this.resources.js; + + // Load resources + return this._rpc({ + route: '/web_editor/get_assets_editor_resources', + params: { + key: this.viewKey, + get_views: !this.options.doNotLoadViews, + get_scss: !this.options.doNotLoadSCSS, + get_js: !this.options.doNotLoadJS, + bundles: this.options.includeBundles, + bundles_restriction: this.options.filesFilter === 'all' ? [] : this.options.defaultBundlesRestriction, + only_user_custom_files: this.options.filesFilter === 'custom', + }, + }).then((function (resources) { + _processViews.call(this, resources.views || []); + _processJSorSCSS.call(this, resources.scss || [], 'scss'); + _processJSorSCSS.call(this, resources.js || [], 'js'); + }).bind(this)); + + function _processViews(views) { + // Only keep the active views and index them by ID. + _.extend(this.views, _.indexBy(_.filter(views, function (view) { + return view.active; + }), 'id')); + + // Initialize a 0 level for each view and assign them an array containing their children. + var self = this; + var roots = []; + _.each(this.views, function (view) { + view.level = 0; + view.children = []; + }); + _.each(this.views, function (view) { + var parentId = view.inherit_id[0]; + var parent = parentId && self.views[parentId]; + if (parent) { + parent.children.push(view); + } else { + roots.push(view); + } + }); + + // Assign the correct level based on children key and save a sorted array where + // each view is followed by their children. + this.sortedViews = []; + function visit(view, level) { + view.level = level; + self.sortedViews.push(view); + _.each(view.children, function (child) { + visit(child, level + 1); + }); + } + _.each(roots, function (root) { + visit(root, 0); + }); + } + + function _processJSorSCSS(data, type) { + // The received scss or js data is already sorted by bundle and DOM order + if (type === 'scss') { + this.sortedSCSS = data; + } else { + this.sortedJS = data; + } + + // Store the URL ungrouped by bundle and use the URL as key (resource ID) + var resources = type === 'scss' ? this.scss : this.js; + _.each(data, function (bundleInfos) { + _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; }); + _.extend(resources, _.indexBy(bundleInfos[1], 'url')); + }); + } + }, + /** + * Forces the view/scss/js file identified by its ID/URL to be reset to the way + * it was before the user started editing it. + * + * @todo views reset is not supported yet + * + * @private + * @param {integer|string} [resID] (default to the currently selected one) + * @param {string} [type] (default to the currently selected one) + * @returns {Promise} + */ + _resetResource: function (resID, type) { + resID = resID || this._getSelectedResource(); + type = type || this.currentType; + + if (this.currentType === 'xml') { + return Promise.reject(_t("Reseting views is not supported yet")); + } else { + var resource = type === 'scss' ? this.scss[resID] : this.js[resID]; + return this._rpc({ + route: '/web_editor/reset_asset', + params: { + url: resID, + bundle_xmlid: resource.bundle_xmlid, + }, + }); + } + }, + /** + * Saves a unique SCSS or JS file. + * + * @private + * @param {Object} session - contains the 'id' (url) and the 'text' of the + * SCSS or JS file to save. + * @return {Promise} status indicates if the save is finished or if an + * error occured. + */ + _saveSCSSorJS: function (session) { + var self = this; + var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js'); + var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid; + var fileType = sessionIdEndsWithJS ? 'js' : 'scss'; + return self._rpc({ + route: '/web_editor/save_asset', + params: { + url: session.id, + bundle_xmlid: bundleXmlID, + content: session.text, + file_type: fileType, + }, + }).then(function () { + self._toggleDirtyInfo(session.id, fileType, false); + }); + }, + /** + * Saves every resource that has been modified. If one cannot be saved, none + * is saved and an error message is displayed. + * + * @private + * @return {Promise} status indicates if the save is finished or if an + * error occured. + */ + _saveResources: function () { + var self = this; + var toSave = {}; + var errorFound = false; + _.each(this.editingSessions, (function (editingSessions, type) { + if (errorFound) return; + + var dirtySessions = _.pick(editingSessions, function (session) { + return session.getUndoManager().hasUndo(); + }); + toSave[type] = _.map(dirtySessions, function (session, resID) { + return { + id: parseInt(resID, 10) || resID, + text: session.getValue(), + }; + }); + + this._showErrorLine(); + for (var i = 0 ; i < toSave[type].length && !errorFound ; i++) { + var check = (type === 'xml' ? checkXML : checkSCSS)(toSave[type][i].text); + if (!check.isValid) { + this._showErrorLine(check.error.line, check.error.message, toSave[type][i].id, type); + errorFound = toSave[type][i]; + } + } + }).bind(this)); + if (errorFound) return Promise.reject(errorFound); + + var defs = []; + var mutex = new concurrency.Mutex(); + _.each(toSave, (function (_toSave, type) { + // Child views first as COW on a parent would delete them + _toSave = _.sortBy(_toSave, 'id').reverse(); + _.each(_toSave, function (session) { + defs.push(mutex.exec(function () { + return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session)); + })); + }); + }).bind(this)); + + var self = this; + return Promise.all(defs).guardedCatch(function (results) { + // some overrides handle errors themselves + if (results === undefined) { + return; + } + var error = results[1]; + Dialog.alert(self, '', { + title: _t("Server error"), + $content: $('<div/>').html( + _t("A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted.") + + '<br/>' + + error + ) + }); + }); + }, + /** + * Saves an unique XML view. + * + * @private + * @param {Object} session - the 'id' and the 'text' of the view to save. + * @returns {Promise} status indicates if the save is finished or if an + * error occured. + */ + _saveView: function (session) { + var self = this; + return new Promise(function (resolve, reject) { + self._rpc({ + model: 'ir.ui.view', + method: 'write', + args: [[session.id], {arch: session.text}], + }, { + noContextKeys: 'lang', + }).then(function () { + self._toggleDirtyInfo(session.id, 'xml', false); + resolve(); + }, function (source, error) { + reject(session, error); + }); + }); + }, + /** + * Shows a line which produced an error. Red color is added to the editor, + * the cursor move to the line and a message is opened on click on the line + * number. If called without argument, the effects are removed. + * + * @private + * @param {integer} [line] - the line number to highlight + * @param {string} [message] - to show on click on the line number + * @param {integer|string} [resID] + * @param {string} [type] + */ + _showErrorLine: function (line, message, resID, type) { + if (line === undefined || line <= 0) { + if (this.$errorLine) { + this.$errorLine.removeClass('o_error'); + this.$errorLine.off('.o_error'); + this.$errorLine = undefined; + this.$errorContent.removeClass('o_error'); + this.$errorContent = undefined; + } + return; + } + + if (type) this._switchType(type); + + if (this._getSelectedResource() === resID) { + __showErrorLine.call(this, line); + } else { + var onChangeSession = (function () { + this.aceEditor.off('changeSession', onChangeSession); + _.delay(__showErrorLine.bind(this, line), 400); + }).bind(this); + this.aceEditor.on('changeSession', onChangeSession); + this._displayResource(resID, this.currentType); + } + + function __showErrorLine(line) { + this.aceEditor.gotoLine(line); + this.$errorLine = this.$viewEditor.find('.ace_gutter-cell').filter(function () { + return parseInt($(this).text()) === line; + }).addClass('o_error'); + this.$errorLine.addClass('o_error').on('click.o_error', function () { + var $message = $('<div/>').html(message); + $message.text($message.text()); + Dialog.alert(this, "", {$content: $message}); + }); + this.$errorContent = this.$viewEditor.find('.ace_scroller').addClass('o_error'); + } + }, + /** + * Switches to the SCSS, XML or JS edition. Calling this method will adapt all + * DOM elements to keep the editor consistent. + * + * @private + * @param {string} type - either 'xml', 'scss' or 'js' + */ + _switchType: function (type) { + this.currentType = type; + this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter('[data-type=' + type + ']').html()); + _.each(this.$lists, function ($list, _type) { $list.toggleClass('d-none', type !== _type); }); + this.$lists[type].change(); + + this.$includeBundlesArea.toggleClass('d-none', this.currentType !== 'xml' || !config.isDebug()); + this.$includeAllSCSSArea.toggleClass('d-none', this.currentType !== 'scss' || !config.isDebug()); + this.$includeAllSCSSArea.find('[data-value="restricted"]').toggleClass('d-none', this.options.defaultBundlesRestriction.length === 0); + this.$formatButton.toggleClass('d-none', this.currentType !== 'xml'); + }, + /** + * Updates the select option DOM element associated with a particular resID + * to indicate if the option is dirty or not. + * + * @private + * @param {integer|string} resID + * @param {string} [type] (default to the currently selected one) + * @param {boolean} [isDirty] true if the view is dirty, default to content + * of UndoManager + */ + _toggleDirtyInfo: function (resID, type, isDirty) { + type = type || this.currentType; + + if (!resID || !this.editingSessions[type][resID]) return; + + var $option = this.$lists[type].find('[value="' + resID + '"]'); + if (isDirty === undefined) { + isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo(); + } + $option.data('dirty', isDirty); + }, + /** + * Renders the content of the view/file <select/> DOM element according to + * current widget data. + * + * @private + */ + _updateViewSelectDOM: function () { + var currentId = this._getSelectedResource(); + + var self = this; + this.$lists.xml.empty(); + _.each(this.sortedViews, function (view) { + self.$lists.xml.append($('<option/>', { + value: view.id, + text: view.name, + selected: currentId === view.id, + 'data-level': view.level, + 'data-debug': view.xml_id, + })); + }); + + this.$lists.scss.empty(); + _populateList(this.sortedSCSS, this.$lists.scss, 5); + + this.$lists.js.empty(); + _populateList(this.sortedJS, this.$lists.js, 3); + + this.$lists.xml.select2('destroy'); + this.$lists.xml.select2({ + formatResult: _formatDisplay.bind(this, false), + formatSelection: _formatDisplay.bind(this, true), + }); + this.$lists.xml.data('select2').dropdown.addClass('o_ace_select2_dropdown'); + this.$lists.scss.select2('destroy'); + this.$lists.scss.select2({ + formatResult: _formatDisplay.bind(this, false), + formatSelection: _formatDisplay.bind(this, true), + }); + this.$lists.scss.data('select2').dropdown.addClass('o_ace_select2_dropdown'); + this.$lists.js.select2('destroy'); + this.$lists.js.select2({ + formatResult: _formatDisplay.bind(this, false), + formatSelection: _formatDisplay.bind(this, true), + }); + this.$lists.js.data('select2').dropdown.addClass('o_ace_select2_dropdown'); + + function _populateList(sortedData, $list, lettersToRemove) { + _.each(sortedData, function (bundleInfos) { + var $optgroup = $('<optgroup/>', { + label: bundleInfos[0].name, + }).appendTo($list); + _.each(bundleInfos[1], function (dataInfo) { + var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove); + $optgroup.append($('<option/>', { + value: dataInfo.url, + text: name, + selected: currentId === dataInfo.url, + 'data-debug': dataInfo.url, + 'data-customized': dataInfo.customized + })); + }); + }); + } + + function _formatDisplay(isSelected, data) { + var $elem = $(data.element); + + var text = data.text || ''; + if (!isSelected) { + text = Array(($elem.data('level') || 0) + 1).join('-') + ' ' + text; + } + var $div = $('<div/>', { + text: text, + class: 'o_ace_select2_result', + }); + + if ($elem.data('dirty') || $elem.data('customized')) { + $div.prepend($('<span/>', { + class: 'mr8 fa fa-floppy-o ' + ($elem.data('dirty') ? 'text-warning' : 'text-success'), + })); + } + + if (!isSelected && config.isDebug() && $elem.data('debug')) { + $div.append($('<span/>', { + text: ' (' + $elem.data('debug') + ')', + class: 'ml4 small text-muted', + })); + } + + return $div; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the close button is clicked -> hides the ace editor. + * + * @private + */ + _onCloseClick: function () { + this.do_hide(); + }, + /** + * Called when the format button is clicked -> format the current resource. + * + * @private + */ + _onFormatClick: function () { + this._formatResource(); + }, + /** + * Called when a filter dropdown item is cliked. Reload the resources + * according to the new filter and make it visually active. + * + * @private + * @param {Event} ev + */ + _onFilterChange: function (ev) { + var $item = $(ev.target); + $item.addClass('active').siblings().removeClass('active'); + if ($item.data('type') === 'xml') { + this.options.includeBundles = $(ev.target).data('value') === 'all'; + } else { + this.options.filesFilter = $item.data('value'); + } + this._loadResources().then(this._updateViewSelectDOM.bind(this)); + }, + /** + * Called when another resource is selected -> displays it. + * + * @private + */ + _onResChange: function () { + this._displayResource(this._getSelectedResource()); + }, + /** + * Called when the reset button is clicked -> resets the resources to its + * original standard odoo state. + * + * @private + */ + _onResetClick: function () { + var self = this; + Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), { + title: _t("Careful !"), + confirm_callback: function () { + self._resetResource(self._getSelectedResource()); + }, + }); + }, + /** + * Called when the save button is clicked -> saves the dirty resources and + * reloads. + * + * @private + */ + _onSaveClick: function (ev) { + const restore = dom.addButtonLoadingEffect(ev.currentTarget); + this._saveResources().then(restore).guardedCatch(restore); + }, + /** + * Called when the user wants to switch from xml to scss or vice-versa -> + * adapt resources choices and displays a resource of that type. + * + * @private + * @param {Event} ev + */ + _onTypeChoice: function (ev) { + ev.preventDefault(); + this._switchType($(ev.target).data('type')); + }, + /** + * Allows to hide the warning message without removing it from the DOM + * -> by default Bootstrap removes alert from the DOM + */ + _onCloseWarningClick: function () { + this.$warningMessage.addClass('d-none'); + }, +}); + +return ViewEditor; +}); diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js new file mode 100644 index 00000000..1cd10318 --- /dev/null +++ b/addons/web_editor/static/src/js/common/utils.js @@ -0,0 +1,266 @@ +odoo.define('web_editor.utils', function (require) { +'use strict'; + +const {ColorpickerWidget} = require('web.Colorpicker'); + +/** + * window.getComputedStyle cannot work properly with CSS shortcuts (like + * 'border-width' which is a shortcut for the top + right + bottom + left border + * widths. If an option wants to customize such a shortcut, it should be listed + * here with the non-shortcuts property it stands for, in order. + * + * @type {Object<string[]>} + */ +const CSS_SHORTHANDS = { + 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'], + 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'], + 'border-color': ['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'], + 'border-style': ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'], +}; +/** + * Key-value mapping to list converters from an unit A to an unit B. + * - The key is a string in the format '$1-$2' where $1 is the CSS symbol of + * unit A and $2 is the CSS symbol of unit B. + * - The value is a function that converts the received value (expressed in + * unit A) to another value expressed in unit B. Two other parameters is + * received: the css property on which the unit applies and the jQuery element + * on which that css property may change. + */ +const CSS_UNITS_CONVERSION = { + 's-ms': () => 1000, + 'ms-s': () => 0.001, + 'rem-px': () => _computePxByRem(), + 'px-rem': () => _computePxByRem(true), +}; +/** + * Colors of the default palette, used for substitution in shapes/illustrations. + * key: number of the color in the palette (ie, o-color-<1-5>) + * value: color hex code + */ +const DEFAULT_PALETTE = { + '1': '#3AADAA', + '2': '#7C6576', + '3': '#F6F6F6', + '4': '#FFFFFF', + '5': '#383E45', +}; + +/** + * Computes the number of "px" needed to make a "rem" unit. Subsequent calls + * returns the cached computed value. + * + * @param {boolean} [toRem=false] + * @returns {float} - number of px by rem if 'toRem' is false + * - the inverse otherwise + */ +function _computePxByRem(toRem) { + if (_computePxByRem.PX_BY_REM === undefined) { + const htmlStyle = window.getComputedStyle(document.documentElement); + _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']); + } + return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM; +} +/** + * Converts the given (value + unit) string to a numeric value expressed in + * the other given css unit. + * + * e.g. fct('400ms', 's') -> 0.4 + * + * @param {string} value + * @param {string} unitTo + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {number} + */ +function _convertValueToUnit(value, unitTo, cssProp, $target) { + const m = _getNumericAndUnit(value); + if (!m) { + return NaN; + } + const numValue = parseFloat(m[0]); + const valueUnit = m[1]; + return _convertNumericToUnit(numValue, valueUnit, unitTo, cssProp, $target); +} +/** + * Converts the given numeric value expressed in the given css unit into + * the corresponding numeric value expressed in the other given css unit. + * + * e.g. fct(400, 'ms', 's') -> 0.4 + * + * @param {number} value + * @param {string} unitFrom + * @param {string} unitTo + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {number} + */ +function _convertNumericToUnit(value, unitFrom, unitTo, cssProp, $target) { + if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) { + return value; + } + const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`]; + if (converter === undefined) { + throw new Error(`Cannot convert '${unitFrom}' units into '${unitTo}' units !`); + } + return value * converter(cssProp, $target); +} +/** + * Returns the numeric value and unit of a css value. + * + * e.g. fct('400ms') -> [400, 'ms'] + * + * @param {string} value + * @returns {Array|null} + */ +function _getNumericAndUnit(value) { + const m = value.trim().match(/^(-?[0-9.]+)([A-Za-z% -]*)$/); + if (!m) { + return null; + } + return [m[1].trim(), m[2].trim()]; +} +/** + * Checks if two css values are equal. + * + * @param {string} value1 + * @param {string} value2 + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {boolean} + */ +function _areCssValuesEqual(value1, value2, cssProp, $target) { + // String comparison first + if (value1 === value2) { + return true; + } + + // It could be a CSS variable, in that case the actual value has to be + // retrieved before comparing. + if (value1.startsWith('var(--')) { + value1 = _getCSSVariableValue(value1.substring(6, value1.length - 1)); + } + if (value2.startsWith('var(--')) { + value2 = _getCSSVariableValue(value2.substring(6, value2.length - 1)); + } + if (value1 === value2) { + return true; + } + + // They may be colors, normalize then re-compare the resulting string + const color1 = ColorpickerWidget.normalizeCSSColor(value1); + const color2 = ColorpickerWidget.normalizeCSSColor(value2); + if (color1 === color2) { + return true; + } + + // Convert the second value in the unit of the first one and compare + // floating values + const data = _getNumericAndUnit(value1); + if (!data) { + return false; + } + const numValue1 = data[0]; + const numValue2 = _convertValueToUnit(value2, data[1], cssProp, $target); + return (Math.abs(numValue1 - numValue2) < Number.EPSILON); +} +/** + * @param {string|number} name + * @returns {boolean} + */ +function _isColorCombinationName(name) { + const number = parseInt(name); + return (!isNaN(number) && number % 100 !== 0); +} +/** + * @param {string[]} colorNames + * @param {string} [prefix='bg-'] + * @returns {string[]} + */ +function _computeColorClasses(colorNames, prefix = 'bg-') { + let hasCCClasses = false; + const isBgPrefix = (prefix === 'bg-'); + const classes = colorNames.map(c => { + if (isBgPrefix && _isColorCombinationName(c)) { + hasCCClasses = true; + return `o_cc${c}`; + } + return (prefix + c); + }); + if (hasCCClasses) { + classes.push('o_cc'); + } + return classes; +} +/** + * @param {string} key + * @param {CSSStyleDeclaration} [htmlStyle] if not provided, it is computed + * @returns {string} + */ +function _getCSSVariableValue(key, htmlStyle) { + if (htmlStyle === undefined) { + htmlStyle = window.getComputedStyle(document.documentElement); + } + // Get trimmed value from the HTML element + let value = htmlStyle.getPropertyValue(`--${key}`).trim(); + // If it is a color value, it needs to be normalized + value = ColorpickerWidget.normalizeCSSColor(value); + // Normally scss-string values are "printed" single-quoted. That way no + // magic conversation is needed when customizing a variable: either save it + // quoted for strings or non quoted for colors, numbers, etc. However, + // Chrome has the annoying behavior of changing the single-quotes to + // double-quotes when reading them through getPropertyValue... + return value.replace(/"/g, "'"); +} +/** + * Normalize a color in case it is a variable name so it can be used outside of + * css. + * + * @param {string} color the color to normalize into a css value + * @returns {string} the normalized color + */ +function _normalizeColor(color) { + if (ColorpickerWidget.isCSSColor(color)) { + return color; + } + return _getCSSVariableValue(color); +} +/** + * Parse an element's background-image's url. + * + * @param {string} string a css value in the form 'url("...")' + * @returns {string|false} the src of the image or false if not parsable + */ +function _getBgImageURL(el) { + const string = $(el).css('background-image'); + const match = string.match(/^url\((['"])(.*?)\1\)$/); + if (!match) { + return ''; + } + const matchedURL = match[2]; + // Make URL relative if possible + const fullURL = new URL(matchedURL, window.location.origin); + if (fullURL.origin === window.location.origin) { + return fullURL.href.slice(fullURL.origin.length); + } + return matchedURL; +} + +return { + CSS_SHORTHANDS: CSS_SHORTHANDS, + CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION, + DEFAULT_PALETTE: DEFAULT_PALETTE, + computePxByRem: _computePxByRem, + convertValueToUnit: _convertValueToUnit, + convertNumericToUnit: _convertNumericToUnit, + getNumericAndUnit: _getNumericAndUnit, + areCssValuesEqual: _areCssValuesEqual, + isColorCombinationName: _isColorCombinationName, + computeColorClasses: _computeColorClasses, + getCSSVariableValue: _getCSSVariableValue, + normalizeColor: _normalizeColor, + getBgImageURL: _getBgImageURL, +}; +}); 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(/"/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; +}); diff --git a/addons/web_editor/static/src/js/frontend/loader.js b/addons/web_editor/static/src/js/frontend/loader.js new file mode 100644 index 00000000..9c6cf0ef --- /dev/null +++ b/addons/web_editor/static/src/js/frontend/loader.js @@ -0,0 +1,28 @@ +odoo.define('web_editor.loader', function (require) { +'use strict'; + +var Wysiwyg = require('web_editor.wysiwyg.root'); + +function load(parent, textarea, options) { + var loading = textarea.nextElementSibling; + if (loading && !loading.classList.contains('o_wysiwyg_loading')) { + loading = null; + } + + if (!textarea.value.match(/\S/)) { + textarea.value = '<p><br/></p>'; + } + + var wysiwyg = new Wysiwyg(parent, options); + return wysiwyg.attachTo(textarea).then(() => { + if (loading) { + loading.parentNode.removeChild(loading); + } + return wysiwyg; + }); +} + +return { + load: load, +}; +}); diff --git a/addons/web_editor/static/src/js/frontend/loader_loading.js b/addons/web_editor/static/src/js/frontend/loader_loading.js new file mode 100644 index 00000000..f5e8eda7 --- /dev/null +++ b/addons/web_editor/static/src/js/frontend/loader_loading.js @@ -0,0 +1,33 @@ +(function () { +'use strict'; + +/** + * This file makes sure textarea elements with a specific editor class are + * tweaked as soon as the DOM is ready so that they appear to be loading. + * + * They must then be loaded using standard Odoo modules system. In particular, + * @see web_editor.loader + */ + +document.addEventListener('DOMContentLoaded', () => { + // Standard loop for better browser support + var textareaEls = document.querySelectorAll('textarea.o_wysiwyg_loader'); + for (var i = 0; i < textareaEls.length; i++) { + var textarea = textareaEls[i]; + var wrapper = document.createElement('div'); + wrapper.classList.add('position-relative', 'o_wysiwyg_wrapper'); + + var loadingElement = document.createElement('div'); + loadingElement.classList.add('o_wysiwyg_loading'); + var loadingIcon = document.createElement('i'); + loadingIcon.classList.add('text-600', 'text-center', + 'fa', 'fa-circle-o-notch', 'fa-spin', 'fa-2x'); + loadingElement.appendChild(loadingIcon); + wrapper.appendChild(loadingElement); + + textarea.parentNode.insertBefore(wrapper, textarea); + wrapper.insertBefore(textarea, loadingElement); + } +}); + +})(); diff --git a/addons/web_editor/static/src/js/wysiwyg/fonts.js b/addons/web_editor/static/src/js/wysiwyg/fonts.js new file mode 100644 index 00000000..257ccaf2 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/fonts.js @@ -0,0 +1,99 @@ +odoo.define('wysiwyg.fonts', function (require) { +'use strict'; + +return { + /** + * Retrieves all the CSS rules which match the given parser (Regex). + * + * @param {Regex} filter + * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is + * defined by 3 values: 'selector', 'css' and 'names'. 'selector' + * is a string which contains the whole selector, 'css' is a string + * which contains the css properties and 'names' is an array of the + * first captured groups for each selector part. E.g.: if the + * filter is set to match .fa-* rules and capture the icon names, + * the rule: + * '.fa-alias1::before, .fa-alias2::before { hello: world; }' + * will be retrieved as + * { + * selector: '.fa-alias1::before, .fa-alias2::before', + * css: 'hello: world;', + * names: ['.fa-alias1', '.fa-alias2'], + * } + */ + cacheCssSelectors: {}, + getCssSelectors: function (filter) { + if (this.cacheCssSelectors[filter]) { + return this.cacheCssSelectors[filter]; + } + this.cacheCssSelectors[filter] = []; + var sheets = document.styleSheets; + for (var i = 0; i < sheets.length; i++) { + var rules; + try { + // try...catch because Firefox not able to enumerate + // document.styleSheets[].cssRules[] for cross-domain + // stylesheets. + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (!rules) { + continue; + } + + for (var r = 0 ; r < rules.length ; r++) { + var selectorText = rules[r].selectorText; + if (!selectorText) { + continue; + } + var selectors = selectorText.split(/\s*,\s*/); + var data = null; + for (var s = 0; s < selectors.length; s++) { + var match = selectors[s].trim().match(filter); + if (!match) { + continue; + } + if (!data) { + data = { + selector: match[0], + css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''), + names: [match[1]] + }; + } else { + data.selector += (', ' + match[0]); + data.names.push(match[1]); + } + } + if (data) { + this.cacheCssSelectors[filter].push(data); + } + } + } + return this.cacheCssSelectors[filter]; + }, + /** + * List of font icons to load by editor. The icons are displayed in the media + * editor and identified like font and image (can be colored, spinned, resized + * with fa classes). + * To add font, push a new object {base, parser} + * + * - base: class who appear on all fonts + * - parser: regular expression used to select all font in css stylesheets + * + * @type Array + */ + fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}], + /** + * Searches the fonts described by the @see fontIcons variable. + */ + computeFonts: _.once(function () { + var self = this; + _.each(this.fontIcons, function (data) { + data.cssData = self.getCssSelectors(data.parser); + data.alias = _.flatten(_.map(data.cssData, _.property('names'))); + }); + }), +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/root.js b/addons/web_editor/static/src/js/wysiwyg/root.js new file mode 100644 index 00000000..57e9f65e --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/root.js @@ -0,0 +1,91 @@ +odoo.define('web_editor.wysiwyg.root', function (require) { +'use strict'; + +var Widget = require('web.Widget'); + +var assetsLoaded = false; + +var WysiwygRoot = Widget.extend({ + assetLibs: ['web_editor.compiled_assets_wysiwyg'], + _loadLibsTplRoute: '/web_editor/public_render_template', + + publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveModifiedImages'], + + /** + * @see 'web_editor.wysiwyg' module + **/ + init: function (parent, params) { + this._super.apply(this, arguments); + this._params = params; + this.$editor = null; + }, + /** + * Load assets + * + * @override + **/ + willStart: function () { + var self = this; + + var $target = this.$el; + this.$el = null; + + return this._super().then(function () { + // FIXME: this code works by pure luck. If the web_editor.wysiwyg + // JS module was requiring a delayed module, using it here right + // away would lead to a crash. + if (!assetsLoaded) { + var Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg']; + _.each(['getRange', 'setRange', 'setRangeFromNode'], function (methodName) { + WysiwygRoot[methodName] = Wysiwyg[methodName].bind(Wysiwyg); + }); + assetsLoaded = true; + } + + var Wysiwyg = self._getWysiwygContructor(); + var instance = new Wysiwyg(self, self._params); + if (self.__extraAssetsForIframe) { + instance.__extraAssetsForIframe = self.__extraAssetsForIframe; + } + self._params = null; + + _.each(self.publicMethods, function (methodName) { + self[methodName] = instance[methodName].bind(instance); + }); + + return instance.attachTo($target).then(function () { + self.$editor = instance.$editor || instance.$el; + }); + }); + }, + + _getWysiwygContructor: function () { + return odoo.__DEBUG__.services['web_editor.wysiwyg']; + } +}); + +return WysiwygRoot; + +}); + +odoo.define('web_editor.wysiwyg.default_options', function (require) { +'use strict'; + +/** + * TODO this should be refactored to be done another way, same as the 'root' + * module that should be done another way. + * + * This allows to have access to default options that are used in the summernote + * editor so that they can be tweaked (instead of entirely replaced) when using + * the editor on an editable content. + */ + +var core = require('web.core'); + +var _lt = core._lt; + +return { + styleTags: ['p', 'pre', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'], + fontSizes: [_lt('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62], +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js new file mode 100644 index 00000000..80f143b6 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js @@ -0,0 +1,62 @@ +odoo.define('wysiwyg.widgets.AltDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var _t = core._t; + +/** + * Let users change the alt & title of a media. + */ +var AltDialog = Dialog.extend({ + template: 'wysiwyg.widgets.alt', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/wysiwyg.xml'] + ), + + /** + * @constructor + */ + init: function (parent, options, media) { + options = options || {}; + this._super(parent, _.extend({}, { + title: _t("Change media description and tooltip") + }, options)); + + this.trigger_up('getRecordInfo', { + recordInfo: options, + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + this.media = media; + var allEscQuots = /"/g; + this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"'); + var title = $(this.media).attr('title') || $(this.media).data('original-title') || ""; + this.tag_title = (title).replace(allEscQuots, '"'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var alt = this.$('#alt').val(); + var title = this.$('#title').val(); + var allNonEscQuots = /"/g; + $(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, """) : null) + .attr('title', title ? title.replace(allNonEscQuots, """) : null); + $(this.media).trigger('content_changed'); + this.final_data = this.media; + return this._super.apply(this, arguments); + }, +}); + + +return AltDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js new file mode 100644 index 00000000..d00abd1a --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js @@ -0,0 +1,410 @@ +odoo.define('web_editor.ColorPalette', function (require) { +'use strict'; + +const ajax = require('web.ajax'); +const core = require('web.core'); +const session = require('web.session'); +const {ColorpickerWidget} = require('web.Colorpicker'); +const Widget = require('web.Widget'); +const summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); +const weUtils = require('web_editor.utils'); + +const qweb = core.qweb; + +const ColorPaletteWidget = Widget.extend({ + // ! for xmlDependencies, see loadDependencies function + template: 'web_editor.snippet.option.colorpicker', + events: { + 'click .o_we_color_btn': '_onColorButtonClick', + 'mouseenter .o_we_color_btn': '_onColorButtonEnter', + 'mouseleave .o_we_color_btn': '_onColorButtonLeave', + 'click .o_we_colorpicker_switch_pane_btn': '_onSwitchPaneButtonClick', + }, + custom_events: { + 'colorpicker_select': '_onColorPickerSelect', + 'colorpicker_preview': '_onColorPickerPreview', + }, + /** + * @override + * + * @param {Object} [options] + * @param {string} [options.selectedColor] The class or css attribute color selected by default. + * @param {boolean} [options.resetButton=true] Whether to display or not the reset button. + * @param {string[]} [options.excluded=[]] Sections not to display. + * @param {string[]} [options.excludeSectionOf] Extra section to exclude: the one containing the named color. + * @param {JQuery} [options.$editable=$()] Editable content from which the custom colors are retrieved. + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.summernoteCustomColorsArray = [].concat(...summernoteCustomColors); + this.style = window.getComputedStyle(document.documentElement); + this.options = _.extend({ + selectedColor: false, + resetButton: true, + excluded: [], + excludeSectionOf: null, + $editable: $(), + withCombinations: false, + }, options || {}); + + this.selectedColor = ''; + this.resetButton = this.options.resetButton; + this.withCombinations = this.options.withCombinations; + + this.trigger_up('request_editable', {callback: val => this.options.$editable = val}); + }, + /** + * @override + */ + willStart: async function () { + await this._super(...arguments); + await ColorPaletteWidget.loadDependencies(this); + }, + /** + * @override + */ + start: async function () { + const res = this._super.apply(this, arguments); + + const $colorSection = this.$('.o_colorpicker_sections[data-color-tab="theme-colors"]'); + const $clpicker = qweb.has_template('web_editor.colorpicker') + ? $(qweb.render('web_editor.colorpicker')) + : $(`<colorpicker><div class="o_colorpicker_section" data-name="common"></div></colorpicker>`); + $clpicker.find('button').addClass('o_we_color_btn'); + $clpicker.appendTo($colorSection); + + // Remove excluded palettes (note: only hide them to still be able + // to remove their related colors on the DOM target) + _.each(this.options.excluded, exc => { + this.$('[data-name="' + exc + '"]').addClass('d-none'); + }); + if (this.options.excludeSectionOf) { + this.$('[data-name]:has([data-color="' + this.options.excludeSectionOf + '"])').addClass('d-none'); + } + + this.el.querySelectorAll('.o_colorpicker_section').forEach(elem => { + $(elem).prepend('<div>' + (elem.dataset.display || '') + '</div>'); + }); + + // Render common colors + if (!this.options.excluded.includes('common')) { + const $commonColorSection = this.$('[data-name="common"]'); + summernoteCustomColors.forEach((colorRow, i) => { + if (i === 0) { + return; // Ignore the summernote gray palette and use ours + } + const $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection); + colorRow.forEach(color => { + $div.append(this._createColorButton(color, ['o_common_color'])); + }); + }); + } + + // Compute class colors + const compatibilityColorNames = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger']; + this.colorNames = [...compatibilityColorNames]; + this.colorToColorNames = {}; + this.el.querySelectorAll('button[data-color]').forEach(elem => { + const colorName = elem.dataset.color; + const $color = $(elem); + const isCCName = weUtils.isColorCombinationName(colorName); + if (isCCName) { + $color.find('.o_we_cc_preview_wrapper').addClass(`o_cc o_cc${colorName}`); + } else { + $color.addClass(`bg-${colorName}`); + } + this.colorNames.push(colorName); + if (!isCCName && !elem.classList.contains('d-none')) { + const color = weUtils.getCSSVariableValue(colorName, this.style); + this.colorToColorNames[color] = colorName; + } + }); + + // Select selected Color and build customColors. + // If no color is selected selectedColor is an empty string (transparent is interpreted as no color) + if (this.options.selectedColor) { + let selectedColor = this.options.selectedColor; + if (compatibilityColorNames.includes(selectedColor)) { + selectedColor = weUtils.getCSSVariableValue(selectedColor, this.style) || selectedColor; + } + selectedColor = ColorpickerWidget.normalizeCSSColor(selectedColor); + if (selectedColor !== 'rgba(0, 0, 0, 0)') { + this.selectedColor = this.colorToColorNames[selectedColor] || selectedColor; + } + } + this._buildCustomColors(); + this._markSelectedColor(); + + // Colorpicker + let defaultColor = this.selectedColor; + if (defaultColor && !ColorpickerWidget.isCSSColor(defaultColor)) { + defaultColor = weUtils.getCSSVariableValue(defaultColor, this.style); + } + this.colorPicker = new ColorpickerWidget(this, { + defaultColor: defaultColor, + }); + await this.colorPicker.prependTo($colorSection); + + // TODO Added as a fix. In master, the widget should probably not be + // instantiated at all. + if (this.options.excluded.includes('custom')) { + this.colorPicker.$el.addClass('d-none'); + } + + return res; + }, + /** + * Return a list of the color names used in the color palette + */ + getColorNames: function () { + return this.colorNames; + }, + /** + * Sets the currently selected color + * + * @param {string} color rgb[a] + */ + setSelectedColor: function (color) { + this._selectColor({color: color}); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _buildCustomColors: function () { + if (this.options.excluded.includes('custom')) { + return; + } + this.el.querySelectorAll('.o_custom_color').forEach(el => el.remove()); + const existingColors = new Set(this.summernoteCustomColorsArray.concat( + Object.keys(this.colorToColorNames) + )); + this.trigger_up('get_custom_colors', { + onSuccess: (colors) => { + colors.forEach(color => { + this._addCustomColor(existingColors, color); + }); + }, + }); + weUtils.getCSSVariableValue('custom-colors', this.style).split(' ').forEach(v => { + const color = weUtils.getCSSVariableValue(v.substring(1, v.length - 1), this.style); + if (ColorpickerWidget.isCSSColor(color)) { + this._addCustomColor(existingColors, color); + } + }); + _.each(this.options.$editable.find('[style*="color"]'), el => { + for (const colorProp of ['color', 'backgroundColor']) { + this._addCustomColor(existingColors, el.style[colorProp]); + } + }); + if (this.selectedColor) { + this._addCustomColor(existingColors, this.selectedColor); + } + }, + /** + * Add the color to the custom color section if it is not in the existingColors. + * + * @param {string[]} existingColors Colors currently in the colorpicker + * @param {string} color Color to add to the cuustom colors + */ + _addCustomColor: function (existingColors, color) { + if (!color) { + return; + } + if (!ColorpickerWidget.isCSSColor(color)) { + color = weUtils.getCSSVariableValue(color, this.style); + } + const normColor = ColorpickerWidget.normalizeCSSColor(color); + if (!existingColors.has(normColor)) { + this._addCustomColorButton(normColor); + existingColors.add(normColor); + } + }, + /** + * Add a custom button in the coresponding section. + * + * @private + * @param {string} color + * @param {string[]} classes - classes added to the button + * @returns {jQuery} + */ + _addCustomColorButton: function (color, classes = []) { + classes.push('o_custom_color'); + const $themeSection = this.$('.o_colorpicker_section[data-name="theme"]'); + const $button = this._createColorButton(color, classes); + return $button.appendTo($themeSection); + }, + /** + * Return a color button. + * + * @param {string} color + * @param {string[]} classes - classes added to the button + * @returns {jQuery} + */ + _createColorButton: function (color, classes) { + return $('<button/>', { + class: 'o_we_color_btn ' + classes.join(' '), + style: 'background-color:' + color + ';', + }); + }, + /** + * Gets normalized information about a color button. + * + * @private + * @param {HTMLElement} buttonEl + * @returns {Object} + */ + _getButtonInfo: function (buttonEl) { + const bgColor = buttonEl.style.backgroundColor; + return { + color: bgColor ? ColorpickerWidget.normalizeCSSColor(bgColor) : buttonEl.dataset.color || '', + target: buttonEl, + }; + }, + /** + * Set the selectedColor and trigger an event + * + * @param {Object} color + * @param {string} [eventName] + */ + _selectColor: function (colorInfo, eventName) { + this.selectedColor = colorInfo.color = this.colorToColorNames[colorInfo.color] || colorInfo.color; + if (eventName) { + this.trigger_up(eventName, colorInfo); + } + this._buildCustomColors(); + this._markSelectedColor(); + this.colorPicker.setSelectedColor(colorInfo.color); + }, + /** + * Mark the selected color + * + * @private + */ + _markSelectedColor: function () { + this.el.querySelectorAll('button.selected').forEach(el => el.classList.remove('selected')); + const selectedButton = this.el.querySelector(`button[data-color="${this.selectedColor}"], button[style*="background-color:${this.selectedColor};"]`); + if (selectedButton) { + selectedButton.classList.add('selected'); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a color button is clicked. + * + * @private + * @param {Event} ev + */ + _onColorButtonClick: function (ev) { + const buttonEl = ev.currentTarget; + const colorInfo = this._getButtonInfo(buttonEl); + this._selectColor(colorInfo, 'color_picked'); + }, + /** + * Called when a color button is entered. + * + * @private + * @param {Event} ev + */ + _onColorButtonEnter: function (ev) { + ev.stopPropagation(); + this.trigger_up('color_hover', this._getButtonInfo(ev.currentTarget)); + }, + /** + * Called when a color button is left the data color is the color currently selected. + * + * @private + * @param {Event} ev + */ + _onColorButtonLeave: function (ev) { + ev.stopPropagation(); + this.trigger_up('color_leave', { + color: this.selectedColor, + target: ev.target, + }); + }, + /** + * Called when an update is made on the colorpicker. + * + * @private + * @param {Event} ev + */ + _onColorPickerPreview: function (ev) { + this.trigger_up('color_hover', { + color: ev.data.cssColor, + target: this.colorPicker.el, + }); + }, + /** + * Called when a color is selected on the colorpicker (mouseup). + * + * @private + * @param {Event} ev + */ + _onColorPickerSelect: function (ev) { + this._selectColor({ + color: ev.data.cssColor, + target: this.colorPicker.el, + }, 'custom_color_picked'); + }, + /** + * @private + * @param {Event} ev + */ + _onSwitchPaneButtonClick(ev) { + ev.stopPropagation(); + this.el.querySelectorAll('.o_we_colorpicker_switch_pane_btn').forEach(el => { + el.classList.remove('active'); + }); + ev.currentTarget.classList.add('active'); + this.el.querySelectorAll('.o_colorpicker_sections').forEach(el => { + el.classList.toggle('d-none', el.dataset.colorTab !== ev.currentTarget.dataset.target); + }); + }, +}); + +//------------------------------------------------------------------------------ +// Static +//------------------------------------------------------------------------------ + +/** + * Load ColorPaletteWidget dependencies. This allows to load them without + * instantiating the widget itself. + * + * @static + */ +let colorpickerTemplateProm; +ColorPaletteWidget.loadDependencies = async function (rpcCapableObj) { + const proms = [ajax.loadXML('/web_editor/static/src/xml/snippets.xml', qweb)]; + + // Public user using the editor may have a colorpalette but with + // the default summernote ones. + if (!session.is_website_user) { + // We can call the colorPalette multiple times but only need 1 rpc + if (!colorpickerTemplateProm && !qweb.has_template('web_editor.colorpicker')) { + colorpickerTemplateProm = rpcCapableObj._rpc({ + model: 'ir.ui.view', + method: 'read_template', + args: ['web_editor.colorpicker'], + }).then(template => { + return qweb.add_template('<templates>' + template + '</templates>'); + }); + } + proms.push(colorpickerTemplateProm); + } + + return Promise.all(proms); +}; + +return { + ColorPaletteWidget: ColorPaletteWidget, +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js new file mode 100644 index 00000000..516aa4be --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js @@ -0,0 +1,81 @@ +odoo.define('wysiwyg.widgets.Dialog', function (require) { +'use strict'; + +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +var _t = core._t; + +/** + * Extend Dialog class to handle save/cancel of edition components. + */ +var SummernoteDialog = Dialog.extend({ + /** + * @constructor + */ + init: function (parent, options) { + this.options = options || {}; + if (config.device.isMobile) { + options.fullscreen = true; + } + this._super(parent, _.extend({}, { + buttons: [{ + text: this.options.save_text || _t("Save"), + classes: 'btn-primary', + click: this.save, + }, + { + text: _t("Discard"), + close: true, + } + ] + }, this.options)); + + this.destroyAction = 'cancel'; + + var self = this; + this.opened(function () { + self.$('input:visible:first').focus(); + self.$el.closest('.modal').addClass('o_web_editor_dialog'); + self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose); + }); + this.on('closed', this, function () { + self._toggleFullScreen(); + this.trigger(this.destroyAction, this.final_data || null); + }); + }, + /** + * Only use on config.device.isMobile, it's used by mass mailing to allow the dialog opening on fullscreen + * @private + */ + _toggleFullScreen: function() { + if (config.device.isMobile && !this.hasFullScreen) { + $('#iframe_target[isMobile="true"] #web_editor-top-edit .o_fullscreen').click(); + } + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Called when the dialog is saved. Set the destroy action type to "save" + * and should set the final_data variable correctly before closing. + */ + save: function () { + this.destroyAction = "save"; + this.close(); + }, + /** + * @override + * @returns {*} + */ + open: function() { + this.hasFullScreen = $(window.top.document.body).hasClass('o_field_widgetTextHtml_fullscreen'); + this._toggleFullScreen(); + return this._super.apply(this, arguments); + }, +}); + +return SummernoteDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js new file mode 100644 index 00000000..27444e06 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js @@ -0,0 +1,213 @@ +odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) { +'use strict'; + +const core = require('web.core'); +const Widget = require('web.Widget'); +const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing'); + +const _t = core._t; + +const ImageCropWidget = Widget.extend({ + template: ['wysiwyg.widgets.crop'], + xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'], + events: { + 'click.crop_options [data-action]': '_onCropOptionClick', + // zoom event is triggered by the cropperjs library when the user zooms. + 'zoom': '_onCropZoom', + }, + + /** + * @constructor + */ + init(parent, media) { + this._super(...arguments); + this.media = media; + this.$media = $(media); + // Needed for editors in iframes. + this.document = media.ownerDocument; + // key: ratio identifier, label: displayed to user, value: used by cropper lib + this.aspectRatios = { + "0/0": {label: _t("Free"), value: 0}, + "16/9": {label: "16:9", value: 16 / 9}, + "4/3": {label: "4:3", value: 4 / 3}, + "1/1": {label: "1:1", value: 1}, + "2/3": {label: "2:3", value: 2 / 3}, + }; + const src = this.media.getAttribute('src'); + const data = Object.assign({}, media.dataset); + this.initialSrc = src; + this.aspectRatio = data.aspectRatio || "0/0"; + this.mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg'; + }, + /** + * @override + */ + async willStart() { + await this._super.apply(this, arguments); + await loadImageInfo(this.media, this._rpc.bind(this)); + if (this.media.dataset.originalSrc) { + this.originalSrc = this.media.dataset.originalSrc; + this.originalId = this.media.dataset.originalId; + return; + } + // Couldn't find an attachment: not croppable. + this.uncroppable = true; + }, + /** + * @override + */ + async start() { + if (this.uncroppable) { + this.displayNotification({ + type: 'warning', + title: _t("This image is an external image"), + message: _t("This type of image is not supported for cropping.<br/>If you want to crop it, please first download it from the original source and upload it in Odoo."), + }); + return this.destroy(); + } + const _super = this._super.bind(this); + const $cropperWrapper = this.$('.o_we_cropper_wrapper'); + + // Replacing the src with the original's so that the layout is correct. + await loadImage(this.originalSrc, this.media); + this.$cropperImage = this.$('.o_we_cropper_img'); + const cropperImage = this.$cropperImage[0]; + [cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px']; + + // Overlaying the cropper image over the real image + const offset = this.$media.offset(); + offset.left += parseInt(this.$media.css('padding-left')); + offset.top += parseInt(this.$media.css('padding-right')); + $cropperWrapper.offset(offset); + + await loadImage(this.originalSrc, cropperImage); + await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset); + core.bus.trigger('deactivate_snippet'); + + this._onDocumentMousedown = this._onDocumentMousedown.bind(this); + // We use capture so that the handler is called before other editor handlers + // like save, such that we can restore the src before a save. + this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true}); + return _super(...arguments); + }, + /** + * @override + */ + destroy() { + if (this.$cropperImage) { + this.$cropperImage.cropper('destroy'); + this.document.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true}); + } + this.media.setAttribute('src', this.initialSrc); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates the DOM image with cropped data and associates required + * information for a potential future save (where required cropped data + * attachments will be created). + * + * @private + */ + async _save() { + // Mark the media for later creation of cropped attachment + this.media.classList.add('o_modified_image_to_save'); + + [...cropperDataFields, 'aspectRatio'].forEach(attr => { + delete this.media.dataset[attr]; + const value = this._getAttributeValue(attr); + if (value) { + this.media.dataset[attr] = value; + } + }); + delete this.media.dataset.resizeWidth; + this.initialSrc = await applyModifications(this.media); + this.$media.trigger('image_cropped'); + this.destroy(); + }, + /** + * Returns an attribute's value for saving. + * + * @private + */ + _getAttributeValue(attr) { + if (cropperDataFields.includes(attr)) { + return this.$cropperImage.cropper('getData')[attr]; + } + return this[attr]; + }, + /** + * Resets the crop box to prevent it going outside the image. + * + * @private + */ + _resetCropBox() { + this.$cropperImage.cropper('clear'); + this.$cropperImage.cropper('crop'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a crop option is clicked -> change the crop area accordingly. + * + * @private + * @param {MouseEvent} ev + */ + _onCropOptionClick(ev) { + const {action, value, scaleDirection} = ev.currentTarget.dataset; + switch (action) { + case 'ratio': + this.$cropperImage.cropper('reset'); + this.aspectRatio = value; + this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value); + break; + case 'zoom': + case 'reset': + this.$cropperImage.cropper(action, value); + break; + case 'rotate': + this.$cropperImage.cropper(action, value); + this._resetCropBox(); + break; + case 'flip': { + const amount = this.$cropperImage.cropper('getData')[scaleDirection] * -1; + return this.$cropperImage.cropper(scaleDirection, amount); + } + case 'apply': + return this._save(); + case 'discard': + return this.destroy(); + } + }, + /** + * Discards crop if the user clicks outside of the widget. + * + * @private + * @param {MouseEvent} ev + */ + _onDocumentMousedown(ev) { + if (document.body.contains(ev.target) && this.$(ev.target).length === 0) { + return this.destroy(); + } + }, + /** + * Resets the cropbox on zoom to prevent crop box overflowing. + * + * @private + */ + async _onCropZoom() { + // Wait for the zoom event to be fully processed before reseting. + await new Promise(res => setTimeout(res, 0)); + this._resetCropBox(); + }, +}); + +return ImageCropWidget; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js new file mode 100644 index 00000000..2a18ba2b --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js @@ -0,0 +1,339 @@ +odoo.define('wysiwyg.widgets.LinkDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var dom = $.summernote.core.dom; +var range = $.summernote.core.range; + +var _t = core._t; + +/** + * Allows to customize link content and style. + */ +var LinkDialog = Dialog.extend({ + template: 'wysiwyg.widgets.link', + xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat([ + '/web_editor/static/src/xml/wysiwyg.xml' + ]), + events: _.extend({}, Dialog.prototype.events || {}, { + 'input': '_onAnyChange', + 'change [name="link_style_color"]': '_onTypeChange', + 'change': '_onAnyChange', + 'input input[name="url"]': '_onURLInput', + }), + + /** + * @constructor + * @param {Boolean} linkInfo.isButton - whether if the target is a button element. + */ + init: function (parent, options, editable, linkInfo) { + this.options = options || {}; + this._super(parent, _.extend({ + title: _t("Link to"), + }, this.options)); + + this.trigger_up('getRecordInfo', { + recordInfo: this.options, + callback: recordInfo => { + _.defaults(this.options, recordInfo); + }, + }); + + this.data = linkInfo || {}; + this.isButton = this.data.isButton; + // Using explicit type 'link' to preserve style when the target is <button class="...btn-link"/>. + this.colorsData = [ + {type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'}, + {type: 'primary', label: _t("Primary"), btnPreview: 'primary'}, + {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'}, + // Note: by compatibility the dialog should be able to remove old + // colors that were suggested like the BS status colors or the + // alpha -> epsilon classes. This is currently done by removing + // all btn-* classes anyway. + ]; + + this.editable = editable; + this.data.className = ""; + this.data.iniClassName = ""; + + var r = this.data.range; + this.needLabel = !r || (r.sc === r.ec && r.so === r.eo); + + if (this.data.range) { + const $el = $(this.data.range.sc).filter(this.isButton ? "button" : "a"); + this.data.iniClassName = $el.attr("class") || ""; + this.colorCombinationClass = false; + let $node = $el; + while ($node.length && !$node.is('body')) { + const className = $node.attr('class') || ''; + const m = className.match(/\b(o_cc\d+)\b/g); + if (m) { + this.colorCombinationClass = m[0]; + break; + } + $node = $node.parent(); + } + this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' '); + + var is_link = this.data.range.isOnAnchor(); + + var sc = r.sc; + var so = r.so; + var ec = r.ec; + var eo = r.eo; + + var nodes; + if (!is_link) { + if (sc.tagName) { + sc = dom.firstChild(so ? sc.childNodes[so] : sc); + so = 0; + } else if (so !== sc.textContent.length) { + if (sc === ec) { + ec = sc = sc.splitText(so); + eo -= so; + } else { + sc = sc.splitText(so); + } + so = 0; + } + if (ec.tagName) { + ec = dom.lastChild(eo ? ec.childNodes[eo-1] : ec); + eo = ec.textContent.length; + } else if (eo !== ec.textContent.length) { + ec.splitText(eo); + } + + nodes = dom.listBetween(sc, ec); + + // browsers can't target a picture or void node + if (dom.isVoid(sc) || dom.isImg(sc)) { + so = dom.listPrev(sc).length-1; + sc = sc.parentNode; + } + if (dom.isBR(ec)) { + eo = dom.listPrev(ec).length-1; + ec = ec.parentNode; + } else if (dom.isVoid(ec) || dom.isImg(sc)) { + eo = dom.listPrev(ec).length; + ec = ec.parentNode; + } + + this.data.range = range.create(sc, so, ec, eo); + $(editable).data("range", this.data.range); + this.data.range.select(); + } else { + nodes = dom.ancestor(sc, dom.isAnchor).childNodes; + } + + if (dom.isImg(sc) && nodes.indexOf(sc) === -1) { + nodes.push(sc); + } + if (nodes.length > 1 || dom.ancestor(nodes[0], dom.isImg)) { + var text = ""; + this.data.images = []; + for (var i=0; i<nodes.length; i++) { + if (dom.ancestor(nodes[i], dom.isImg)) { + this.data.images.push(dom.ancestor(nodes[i], dom.isImg)); + text += '[IMG]'; + } else if (!is_link && nodes[i].nodeType === 1) { + // just use text nodes from listBetween + } else if (!is_link && i===0) { + text += nodes[i].textContent.slice(so, Infinity); + } else if (!is_link && i===nodes.length-1) { + text += nodes[i].textContent.slice(0, eo); + } else { + text += nodes[i].textContent; + } + } + this.data.text = text; + } + } + + this.data.text = this.data.text.replace(/[ \t\r\n]+/g, ' '); + + var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi; + var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi; + this.data.className = this.data.iniClassName + .replace(allBtnClassSuffixes, ' ') + .replace(allBtnShapes, ' '); + // 'o_submit' class will force anchor to be handled as a button in linkdialog. + if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) { + this.isButton = true; + } + }, + /** + * @override + */ + start: function () { + this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse'); + + this.$styleInputs = this.$('input.link-style'); + this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true); + if (this.data.iniClassName) { + _.each(this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option'), el => { + var $option = $(el); + if ($option.val() && this.data.iniClassName.match(new RegExp('(^|btn-| |btn-outline-)' + $option.val()))) { + if ($option.is("input")) { + $option.prop("checked", true); + } else { + $option.parent().find('option').removeAttr('selected').removeProp('selected'); + $option.parent().val($option.val()); + $option.attr('selected', 'selected').prop('selected', 'selected'); + } + } + }); + } + if (this.data.url) { + var match = /mailto:(.+)/.exec(this.data.url); + this.$('input[name="url"]').val(match ? match[1] : this.data.url); + this._onURLInput(); + } + + this._updateOptionsUI(); + this._adaptPreview(); + + this.$('input:visible:first').focus(); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var data = this._getData(); + if (data === null) { + var $url = this.$('input[name="url"]'); + $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); + $url.focus(); + return Promise.reject(); + } + this.data.text = data.label; + this.data.url = data.url; + var allWhitespace = /\s+/gi; + var allStartAndEndSpace = /^\s+|\s+$/gi; + var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi; + this.data.className = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''); + if (data.classes.replace(allBtnTypes, ' ')) { + this.data.style = { + 'background-color': '', + 'color': '', + }; + } + this.data.isNewWindow = data.isNewWindow; + this.final_data = this.data; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adapt the link preview to changes. + * + * @private + */ + _adaptPreview: function () { + var data = this._getData(); + if (data === null) { + return; + } + const attrs = { + target: data.isNewWindow ? '_blank' : '', + href: data.url && data.url.length ? data.url : '#', + class: `${data.classes.replace(/float-\w+/, '')} o_btn_preview`, + }; + this.$("#link-preview").attr(attrs).html((data.label && data.label.length) ? data.label : data.url); + }, + /** + * Get the link's data (url, label and styles). + * + * @private + * @returns {Object} {label: String, url: String, classes: String, isNewWindow: Boolean} + */ + _getData: function () { + var $url = this.$('input[name="url"]'); + var url = $url.val(); + var label = _.escape(this.$('input[name="label"]').val() || url); + + if (label && this.data.images) { + for (var i = 0; i < this.data.images.length; i++) { + label = label.replace(/\[IMG\]/, this.data.images[i].outerHTML); + } + } + + if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) { + return null; + } + + const type = this.$('input[name="link_style_color"]:checked').val() || ''; + const size = this.$('select[name="link_style_size"]').val() || ''; + const shape = this.$('select[name="link_style_shape"]').val() || ''; + const shapes = shape ? shape.split(',') : []; + const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : ''; + const shapeClasses = shapes.slice(style ? 1 : 0).join(' '); + const classes = (this.data.className || '') + + (type ? (` btn btn-${style}${type}`) : '') + + (shapeClasses ? (` ${shapeClasses}`) : '') + + (size ? (' btn-' + size) : ''); + var isNewWindow = this.$('input[name="is_new_window"]').prop('checked'); + if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) { + url = ('mailto:' + url); + } else if (url.indexOf(location.origin) === 0 && this.$('#o_link_dialog_url_strip_domain').prop("checked")) { + url = url.slice(location.origin.length); + } + var allWhitespace = /\s+/gi; + var allStartAndEndSpace = /^\s+|\s+$/gi; + return { + label: label, + url: url, + classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''), + isNewWindow: isNewWindow, + }; + }, + /** + * @private + */ + _updateOptionsUI: function () { + const el = this.el.querySelector('[name="link_style_color"]:checked'); + $(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAnyChange: function () { + this._adaptPreview(); + }, + /** + * @private + */ + _onTypeChange() { + this._updateOptionsUI(); + }, + /** + * @private + */ + _onURLInput: function () { + var $linkUrlInput = this.$('#o_link_dialog_url_input'); + $linkUrlInput.closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); + let value = $linkUrlInput.val(); + let isLink = value.indexOf('@') < 0; + this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink); + this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0); + }, +}); + +return LinkDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js new file mode 100644 index 00000000..99ecabb1 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -0,0 +1,1463 @@ +odoo.define('wysiwyg.widgets.media', 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 fonts = require('wysiwyg.fonts'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); +var session = require('web.session'); +const {removeOnImageChangeAttrs} = require('web_editor.image_processing'); +const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils'); + +var QWeb = core.qweb; +var _t = core._t; + +var MediaWidget = Widget.extend({ + xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'], + + /** + * @constructor + * @param {Element} media: the target Element for which we select a media + * @param {Object} options: useful parameters such as res_id, res_model, + * context, user_id, ... + */ + init: function (parent, media, options) { + this._super.apply(this, arguments); + this.media = media; + this.$media = $(media); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @todo comment + */ + clear: function () { + if (!this.media) { + return; + } + this._clear(); + }, + /** + * Saves the currently configured media on the target media. + * + * @abstract + * @returns {Promise} + */ + save: function () {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @abstract + */ + _clear: function () {}, +}); + +var SearchableMediaWidget = MediaWidget.extend({ + events: _.extend({}, MediaWidget.prototype.events || {}, { + 'input .o_we_search': '_onSearchInput', + }), + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + this._onSearchInput = _.debounce(this._onSearchInput, 500); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Finds and displays existing attachments related to the target media. + * + * @abstract + * @param {string} needle: only return attachments matching this parameter + * @returns {Promise} + */ + search: function (needle) {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders thumbnails for the attachments. + * + * @abstract + * @returns {Promise} + */ + _renderThumbnails: function () {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onSearchInput: function (ev) { + this.attachments = []; + this.search($(ev.currentTarget).val() || '').then(() => this._renderThumbnails()); + this.hasSearched = true; + }, +}); + +/** + * Let users choose a file, including uploading a new file in odoo. + */ +var FileWidget = SearchableMediaWidget.extend({ + events: _.extend({}, SearchableMediaWidget.prototype.events || {}, { + 'click .o_upload_media_button': '_onUploadButtonClick', + 'change .o_file_input': '_onFileInputChange', + 'click .o_upload_media_url_button': '_onUploadURLButtonClick', + 'input .o_we_url_input': '_onURLInputChange', + 'click .o_existing_attachment_cell': '_onAttachmentClick', + 'click .o_existing_attachment_remove': '_onRemoveClick', + 'click .o_load_more': '_onLoadMoreClick', + }), + existingAttachmentsTemplate: undefined, + + IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'], + NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30, + MAX_DB_ATTACHMENTS: 5, + + /** + * @constructor + */ + init: function (parent, media, options) { + this._super.apply(this, arguments); + this._mutex = new concurrency.Mutex(); + + this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY; + + this.options = _.extend({ + mediaWidth: media && media.parentElement && $(media.parentElement).width(), + useMediaLibrary: true, + }, options || {}); + + this.attachments = []; + this.selectedAttachments = []; + this.libraryMedia = []; + this.selectedMedia = []; + + this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick); + }, + /** + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + var self = this; + this.$urlInput = this.$('.o_we_url_input'); + this.$form = this.$('form'); + this.$fileInput = this.$('.o_file_input'); + this.$uploadButton = this.$('.o_upload_media_button'); + this.$addUrlButton = this.$('.o_upload_media_url_button'); + this.$urlSuccess = this.$('.o_we_url_success'); + this.$urlWarning = this.$('.o_we_url_warning'); + this.$urlError = this.$('.o_we_url_error'); + this.$errorText = this.$('.o_we_error_text'); + + // If there is already an attachment on the target, select by default + // that attachment if it is among the loaded images. + var o = { + url: null, + alt: null, + }; + if (this.$media.is('img')) { + o.url = this.$media.attr('src'); + } else if (this.$media.is('a.o_image')) { + o.url = this.$media.attr('href').replace(/[?].*/, ''); + o.id = +o.url.match(/\/web\/content\/(\d+)/, '')[1]; + } + + return this.search('').then(async () => { + await this._renderThumbnails(); + if (o.url) { + self._selectAttachement(_.find(self.attachments, function (attachment) { + return o.url === attachment.image_src; + }) || o); + } + return def; + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Saves the currently selected image on the target media. If new files are + * currently being added, delays the save until all files have been added. + * + * @override + */ + save: function () { + return this._mutex.exec(this._save.bind(this)); + }, + /** + * @override + */ + search: function (needle) { + this.needle = needle; + return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0); + }, + /** + * @param {Number} number - the number of attachments to fetch + * @param {Number} offset - from which result to start fetching + */ + fetchAttachments: function (number, offset) { + return this._rpc({ + model: 'ir.attachment', + method: 'search_read', + args: [], + kwargs: { + domain: this._getAttachmentsDomain(this.needle), + fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'], + order: [{name: 'id', asc: false}], + context: this.options.context, + // Try to fetch first record of next page just to know whether there is a next page. + limit: number + 1, + offset: offset, + }, + }).then(attachments => { + this.attachments = this.attachments.slice(); + Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments)); + }); + }, + /** + * Computes whether there is content to display in the template. + */ + hasContent() { + return this.attachments.length; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _clear: function () { + this.media.className = this.media.className && this.media.className.replace(/(^|\s+)(o_image)(?=\s|$)/g, ' '); + }, + /** + * Returns the domain for attachments used in media dialog. + * We look for attachments related to the current document. If there is a value for the model + * field, it is used to search attachments, and the attachments from the current document are + * filtered to display only user-created documents. + * In the case of a wizard such as mail, we have the documents uploaded and those of the model + * + * @private + * @params {string} needle + * @returns {Array} "ir.attachment" odoo domain. + */ + _getAttachmentsDomain: function (needle) { + var domain = this.options.attachmentIDs && this.options.attachmentIDs.length ? ['|', ['id', 'in', this.options.attachmentIDs]] : []; + + var attachedDocumentDomain = [ + '&', + ['res_model', '=', this.options.res_model], + ['res_id', '=', this.options.res_id|0] + ]; + // if the document is not yet created, do not see the documents of other users + if (!this.options.res_id) { + attachedDocumentDomain.unshift('&'); + attachedDocumentDomain.push(['create_uid', '=', this.options.user_id]); + } + if (this.options.data_res_model) { + var relatedDomain = ['&', + ['res_model', '=', this.options.data_res_model], + ['res_id', '=', this.options.data_res_id|0]]; + if (!this.options.data_res_id) { + relatedDomain.unshift('&'); + relatedDomain.push(['create_uid', '=', session.uid]); + } + domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain); + } else { + domain = domain.concat(attachedDocumentDomain); + } + domain = ['|', ['public', '=', true]].concat(domain); + domain = domain.concat(this.options.mimetypeDomain); + if (needle && needle.length) { + domain.push(['name', 'ilike', needle]); + } + if (!this.options.useMediaLibrary) { + domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']); + } + domain.push('!', ['name', '=like', '%.crop']); + domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']); + return domain; + }, + /** + * @private + */ + _highlightSelected: function () { + var self = this; + this.$('.o_existing_attachment_cell.o_we_attachment_selected').removeClass("o_we_attachment_selected"); + _.each(this.selectedAttachments, function (attachment) { + self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']') + .addClass("o_we_attachment_selected").css('display', ''); + }); + }, + /** + * @private + * @param {object} attachment + */ + _handleNewAttachment: function (attachment) { + this.attachments = this.attachments.filter(att => att.id !== attachment.id); + this.attachments.unshift(attachment); + this._renderThumbnails(); + this._selectAttachement(attachment); + }, + /** + * @private + * @returns {Promise} + */ + _loadMoreImages: function (forceSearch) { + return this.fetchAttachments(10, this.numberOfAttachmentsToDisplay).then(() => { + this.numberOfAttachmentsToDisplay += 10; + if (!forceSearch) { + this._renderThumbnails(); + return Promise.resolve(); + } else { + return this.search(this.$('.o_we_search').val() || ''); + } + }); + }, + /** + * Renders the existing attachments and returns the result as a string. + * + * @param {Object[]} attachments + * @returns {string} + */ + _renderExisting: function (attachments) { + return QWeb.render(this.existingAttachmentsTemplate, { + attachments: attachments, + widget: this, + }); + }, + /** + * @private + */ + _renderThumbnails: function () { + var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay); + + // Render menu & content + this.$('.o_we_existing_attachments').replaceWith( + this._renderExisting(attachments) + ); + + this._highlightSelected(); + + // adapt load more + this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent()); + var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length; + var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length; + this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad); + this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad); + }, + /** + * @private + * @returns {Promise} + */ + _save: async function () { + // Create all media-library attachments. + const toSave = Object.fromEntries(this.selectedMedia.map(media => [ + media.id, { + query: media.query || '', + is_dynamic_svg: !!media.isDynamicSVG, + } + ])); + let mediaAttachments = []; + if (Object.keys(toSave).length !== 0) { + mediaAttachments = await this._rpc({ + route: '/web_editor/save_library_media', + params: { + media: toSave, + }, + }); + } + const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => { + // Color-customize dynamic SVGs with the primary theme color + if (attachment.image_src && attachment.image_src.startsWith('/web_editor/shape/')) { + const colorCustomizedURL = new URL(attachment.image_src, window.location.origin); + colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1')); + attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search; + } + return attachment; + }); + if (this.options.multiImages) { + return selected; + } + + const img = selected[0]; + if (!img || !img.id || this.$media.attr('src') === img.image_src) { + return this.media; + } + + if (!img.public && !img.access_token) { + await this._rpc({ + model: 'ir.attachment', + method: 'generate_access_token', + args: [[img.id]] + }).then(function (access_token) { + img.access_token = access_token[0]; + }); + } + + if (img.image_src) { + var src = img.image_src; + if (!img.public && img.access_token) { + src += _.str.sprintf('?access_token=%s', img.access_token); + } + if (!this.$media.is('img')) { + + // Note: by default the images receive the bootstrap opt-in + // img-fluid class. We cannot make them all responsive + // by design because of libraries and client databases img. + this.$media = $('<img/>', {class: 'img-fluid o_we_custom_image'}); + this.media = this.$media[0]; + } + this.$media.attr('src', src); + } else { + if (!this.$media.is('a')) { + $('.note-control-selection').hide(); + this.$media = $('<a/>'); + this.media = this.$media[0]; + } + var href = '/web/content/' + img.id + '?'; + if (!img.public && img.access_token) { + href += _.str.sprintf('access_token=%s&', img.access_token); + } + href += 'unique=' + img.checksum + '&download=true'; + this.$media.attr('href', href); + this.$media.addClass('o_image').attr('title', img.name); + } + + this.$media.attr('alt', img.alt || img.description || ''); + var style = this.style; + if (style) { + this.$media.css(style); + } + + // Remove image modification attributes + removeOnImageChangeAttrs.forEach(attr => { + delete this.media.dataset[attr]; + }); + // Add mimetype for documents + if (!img.image_src) { + this.media.dataset.mimetype = img.mimetype; + } + this.media.classList.remove('o_modified_image_to_save'); + this.$media.trigger('image_changed'); + return this.media; + }, + /** + * @param {object} attachment + * @param {boolean} [save=true] to save the given attachment in the DOM and + * and to close the media dialog + * @private + */ + _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) { + const possibleProps = { + 'attachment': 'selectedAttachments', + 'media': 'selectedMedia' + }; + const prop = possibleProps[type]; + if (this.options.multiImages) { + // if the clicked attachment is already selected then unselect it + // unless it was a save request (then keep the current selection) + const index = this[prop].indexOf(attachment); + if (index !== -1) { + if (!save) { + this[prop].splice(index, 1); + } + } else { + // if the clicked attachment is not selected, add it to selected + this[prop].push(attachment); + } + } else { + Object.values(possibleProps).forEach(prop => { + this[prop] = []; + }); + // select the clicked attachment + this[prop] = [attachment]; + } + this._highlightSelected(); + if (save) { + this.trigger_up('save_request'); + } + }, + /** + * Updates the add by URL UI. + * + * @private + * @param {boolean} emptyValue + * @param {boolean} isURL + * @param {boolean} isImage + */ + _updateAddUrlUi: function (emptyValue, isURL, isImage) { + this.$addUrlButton.toggleClass('btn-secondary', emptyValue) + .toggleClass('btn-primary', !emptyValue) + .prop('disabled', !isURL); + this.$urlSuccess.toggleClass('d-none', !isURL); + this.$urlError.toggleClass('d-none', emptyValue || isURL); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAttachmentClick: function (ev) { + const attachment = ev.currentTarget; + const {id: attachmentID, mediaId} = attachment.dataset; + if (attachmentID) { + const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID)); + this._selectAttachement(attachment, !this.options.multiImages); + } else if (mediaId) { + const media = this.libraryMedia.find(media => media.id === parseInt(mediaId)); + this._selectAttachement(media, !this.options.multiImages, {type: 'media'}); + } + }, + /** + * Handles change of the file input: create attachments with the new files + * and open the Preview dialog for each of them. Locks the save button until + * all new files have been processed. + * + * @private + * @returns {Promise} + */ + _onFileInputChange: function () { + return this._mutex.exec(this._addData.bind(this)); + }, + /** + * Uploads the files that are currently selected on the file input, which + * creates new attachments. Then inserts them on the media dialog and + * selects them. If multiImages is not set, also triggers up the + * save_request event to insert the attachment in the DOM. + * + * @private + * @returns {Promise} + */ + async _addData() { + let files = this.$fileInput[0].files; + if (!files.length) { + // Case if the input is emptied, return resolved promise + return; + } + + var self = this; + var uploadMutex = new concurrency.Mutex(); + + // Upload the smallest file first to block the user the least possible. + files = _.sortBy(files, 'size'); + _.each(files, function (file) { + // Upload one file at a time: no need to parallel as upload is + // limited by bandwidth. + uploadMutex.exec(function () { + return utils.getDataURLFromFile(file).then(function (result) { + return self._rpc({ + route: '/web_editor/attachment/add_data', + params: { + 'name': file.name, + 'data': result.split(',')[1], + 'res_id': self.options.res_id, + 'res_model': self.options.res_model, + 'width': 0, + 'quality': 0, + }, + }).then(function (attachment) { + self._handleNewAttachment(attachment); + }); + }); + }); + }); + + return uploadMutex.getUnlockedDef().then(function () { + if (!self.options.multiImages && !self.noSave) { + self.trigger_up('save_request'); + } + self.noSave = false; + }); + }, + /** + * @private + */ + _onRemoveClick: function (ev) { + var self = this; + ev.stopPropagation(); + Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), { + confirm_callback: function () { + var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell'); + var id = parseInt($a.data('id'), 10); + var attachment = _.findWhere(self.attachments, {id: id}); + return self._rpc({ + route: '/web_editor/attachment/remove', + params: { + ids: [id], + }, + }).then(function (prevented) { + if (_.isEmpty(prevented)) { + self.attachments = _.without(self.attachments, attachment); + self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id); + if (!self.attachments.length) { + self._renderThumbnails(); //render the message and image if empty + } else { + $a.closest('.o_existing_attachment_cell').remove(); + } + return; + } + self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', { + views: prevented[id], + widget: self, + })); + }); + } + }); + }, + /** + * @private + */ + _onURLInputChange: function () { + var inputValue = this.$urlInput.val(); + var emptyValue = (inputValue === ''); + + var isURL = /^.+\..+$/.test(inputValue); // TODO improve + var isImage = _.any(['.gif', '.jpeg', '.jpe', '.jpg', '.png'], function (format) { + return inputValue.endsWith(format); + }); + + this._updateAddUrlUi(emptyValue, isURL, isImage); + }, + /** + * @private + */ + _onUploadButtonClick: function () { + this.$fileInput.click(); + }, + /** + * @private + */ + _onUploadURLButtonClick: function () { + if (this.$urlInput.is('.o_we_horizontal_collapse')) { + this.$urlInput.removeClass('o_we_horizontal_collapse'); + this.$addUrlButton.attr('disabled', 'disabled'); + return; + } + return this._mutex.exec(this._addUrl.bind(this)); + }, + /** + * @private + * @returns {Promise} + */ + _addUrl: function () { + var self = this; + return this._rpc({ + route: '/web_editor/attachment/add_url', + params: { + 'url': this.$urlInput.val(), + 'res_id': this.options.res_id, + 'res_model': this.options.res_model, + }, + }).then(function (attachment) { + self.$urlInput.val(''); + self._onURLInputChange(); + self._handleNewAttachment(attachment); + if (!self.options.multiImages) { + self.trigger_up('save_request'); + } + }); + }, + /** + * @private + */ + _onLoadMoreClick: function () { + this._loadMoreImages(); + }, + /** + * @override + */ + _onSearchInput: function () { + this.attachments = []; + this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY; + this._super.apply(this, arguments); + }, +}); + +/** + * Let users choose an image, including uploading a new image in odoo. + */ +var ImageWidget = FileWidget.extend({ + template: 'wysiwyg.widgets.image', + existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments', + events: Object.assign({}, FileWidget.prototype.events, { + 'change input.o_we_show_optimized': '_onShowOptimizedChange', + 'change .o_we_search_select': '_onSearchSelect', + }), + MIN_ROW_HEIGHT: 128, + + /** + * @constructor + */ + init: function (parent, media, options) { + this.searchService = 'all'; + options = _.extend({ + accept: 'image/*', + mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]], + }, options || {}); + // Binding so we can add/remove it as an addEventListener + this._onAttachmentImageLoad = this._onAttachmentImageLoad.bind(this); + this._super(parent, media, options); + }, + /** + * @override + */ + start: async function () { + await this._super(...arguments); + this.el.addEventListener('load', this._onAttachmentImageLoad, true); + }, + /** + * @override + */ + destroy: function () { + this.el.removeEventListener('load', this._onAttachmentImageLoad, true); + return this._super(...arguments); + }, + /** + * @override + */ + async fetchAttachments(number, offset) { + if (this.needle && this.searchService !== 'database') { + number = this.MAX_DB_ATTACHMENTS; + offset = 0; + } + const result = await this._super(number, offset); + // Color-substitution for dynamic SVG attachment + const primaryColor = getCSSVariableValue('o-color-1'); + this.attachments.forEach(attachment => { + if (attachment.image_src.startsWith('/')) { + const newURL = new URL(attachment.image_src, window.location.origin); + // Set the main color of dynamic SVGs to o-color-1 + if (attachment.image_src.startsWith('/web_editor/shape/')) { + newURL.searchParams.set('c1', primaryColor); + } else { + // Set height so that db images load faster + newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT); + } + attachment.thumbnail_src = newURL.pathname + newURL.search; + } + }); + if (this.needle && this.options.useMediaLibrary) { + try { + const response = await this._rpc({ + route: '/web_editor/media_library_search', + params: { + 'query': this.needle, + 'offset': this.libraryMedia.length, + }, + }); + const newMedia = response.media; + this.nbMediaResults = response.results; + this.libraryMedia.push(...newMedia); + } catch (e) { + // Either API endpoint doesn't exist or is misconfigured. + console.error(`Couldn't reach API endpoint.`); + } + } + return result; + }, + /** + * @override + */ + hasContent() { + if (this.searchService === 'all') { + return this._super(...arguments) || this.libraryMedia.length; + } else if (this.searchService === 'media-library') { + return !!this.libraryMedia.length; + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _updateAddUrlUi: function (emptyValue, isURL, isImage) { + this._super.apply(this, arguments); + this.$addUrlButton.text((isURL && !isImage) ? _t("Add as document") : _t("Add image")); + const warning = isURL && !isImage; + this.$urlWarning.toggleClass('d-none', !warning); + if (warning) { + this.$urlSuccess.addClass('d-none'); + } + }, + /** + * @override + */ + _renderThumbnails: function () { + const alreadyLoaded = this.$('.o_existing_attachment_cell[data-loaded="true"]'); + this._super(...arguments); + // Hide images until they're loaded + this.$('.o_existing_attachment_cell').addClass('d-none'); + // Replace images that had been previously loaded if any to prevent scroll resetting to top + alreadyLoaded.each((index, el) => { + const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`); + if (toReplace.length) { + toReplace.replaceWith(el); + } + }); + this._toggleOptimized(this.$('input.o_we_show_optimized')[0].checked); + // Placeholders have a 3:2 aspect ratio like most photos. + const placeholderWidth = 3 / 2 * this.MIN_ROW_HEIGHT; + this.$('.o_we_attachment_placeholder').css({ + flexGrow: placeholderWidth, + flexBasis: placeholderWidth, + }); + if (this.needle && ['media-library', 'all'].includes(this.searchService)) { + const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults; + const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15; + this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad); + this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad); + } + }, + /** + * @override + */ + _renderExisting: function (attachments) { + if (this.needle && this.searchService !== 'database') { + attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS); + } + return QWeb.render(this.existingAttachmentsTemplate, { + attachments: attachments, + libraryMedia: this.libraryMedia, + widget: this, + }); + }, + /** + * @private + * + * @param {boolean} value whether to toggle optimized attachments on or off + */ + _toggleOptimized: function (value) { + this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important')); + }, + /** + * @override + */ + _highlightSelected: function () { + this._super(...arguments); + this.selectedMedia.forEach(media => { + this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`) + .addClass("o_we_attachment_selected"); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onAttachmentImageLoad: async function (ev) { + const img = ev.target; + const cell = img.closest('.o_existing_attachment_cell'); + if (!cell) { + return; + } + if (cell.dataset.mediaId && !img.src.startsWith('blob')) { + const mediaUrl = img.src; + try { + const response = await fetch(mediaUrl); + if (response.headers.get('content-type') === 'image/svg+xml') { + const svg = await response.text(); + const colorRegex = new RegExp(DEFAULT_PALETTE['1'], 'gi'); + if (colorRegex.test(svg)) { + const fileName = mediaUrl.split('/').pop(); + const file = new File([svg.replace(colorRegex, getCSSVariableValue('o-color-1'))], fileName, { + type: "image/svg+xml", + }); + img.src = URL.createObjectURL(file); + const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId)); + if (media) { + media.isDynamicSVG = true; + } + // We changed the src: wait for the next load event to do the styling + return; + } + } + } catch (e) { + console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.'); + } + } + let aspectRatio = img.naturalWidth / img.naturalHeight; + // Special case for SVGs with no instrinsic sizes on firefox + // See https://github.com/whatwg/html/issues/3510#issuecomment-369982529 + if (img.naturalHeight === 0) { + img.width = 1000; + // Position fixed so that the image doesn't affect layout while rendering + img.style.position = 'fixed'; + // Make invisible so the image doesn't briefly appear on the screen + img.style.opacity = '0'; + // Image needs to be in the DOM for dimensions to be correct after render + const originalParent = img.parentElement; + document.body.appendChild(img); + + aspectRatio = img.width / img.height; + originalParent.appendChild(img); + img.removeAttribute('width'); + img.style.removeProperty('position'); + img.style.removeProperty('opacity'); + } + const width = aspectRatio * this.MIN_ROW_HEIGHT; + cell.style.flexGrow = width; + cell.style.flexBasis = `${width}px`; + cell.classList.remove('d-none'); + cell.classList.add('d-flex'); + cell.dataset.loaded = 'true'; + }, + /** + * @override + */ + _onShowOptimizedChange: function (ev) { + this._toggleOptimized(ev.target.checked); + }, + /** + * @override + */ + _onSearchSelect: function (ev) { + const {value} = ev.target; + this.searchService = value; + this.$('.o_we_search').trigger('input'); + }, + /** + * @private + */ + _onSearchInput: function (ev) { + this.libraryMedia = []; + this._super(...arguments); + }, + /** + * @override + */ + _clear: function (type) { + // Not calling _super: we don't want to call the document widget's _clear method on images + var allImgClasses = /(^|\s+)(img|img-\S*|o_we_custom_image|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g; + this.media.className = this.media.className && this.media.className.replace(allImgClasses, ' '); + }, +}); + + +/** + * Let users choose a document, including uploading a new document in odoo. + */ +var DocumentWidget = FileWidget.extend({ + template: 'wysiwyg.widgets.document', + existingAttachmentsTemplate: 'wysiwyg.widgets.document.existing.attachments', + + /** + * @constructor + */ + init: function (parent, media, options) { + options = _.extend({ + accept: '*/*', + mimetypeDomain: [['mimetype', 'not in', this.IMAGE_MIMETYPES]], + }, options || {}); + this._super(parent, media, options); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _updateAddUrlUi: function (emptyValue, isURL, isImage) { + this._super.apply(this, arguments); + this.$addUrlButton.text((isURL && isImage) ? _t("Add as image") : _t("Add document")); + const warning = isURL && isImage; + this.$urlWarning.toggleClass('d-none', !warning); + if (warning) { + this.$urlSuccess.addClass('d-none'); + } + }, + /** + * @override + */ + _getAttachmentsDomain: function (needle) { + var domain = this._super.apply(this, arguments); + // the assets should not be part of the documents + return domain.concat('!', utils.assetsDomain()); + }, +}); + +/** + * Let users choose a font awesome icon, support all font awesome loaded in the + * css files. + */ +var IconWidget = SearchableMediaWidget.extend({ + template: 'wysiwyg.widgets.font-icons', + events: _.extend({}, SearchableMediaWidget.prototype.events || {}, { + 'click .font-icons-icon': '_onIconClick', + }), + + /** + * @constructor + */ + init: function (parent, media) { + this._super.apply(this, arguments); + + fonts.computeFonts(); + this.iconsParser = fonts.fontIcons; + this.alias = _.flatten(_.map(this.iconsParser, function (data) { + return data.alias; + })); + }, + /** + * @override + */ + start: function () { + this.$icons = this.$('.font-icons-icon'); + var classes = (this.media && this.media.className || '').split(/\s+/); + for (var i = 0; i < classes.length; i++) { + var cls = classes[i]; + if (_.contains(this.alias, cls)) { + this.selectedIcon = cls; + this.initialIcon = cls; + this._highlightSelectedIcon(); + } + } + // Kept for compat in stable, no longer in use: remove in master + this.nonIconClasses = _.without(classes, 'media_iframe_video', this.selectedIcon); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var style = this.$media.attr('style') || ''; + var iconFont = this._getFont(this.selectedIcon) || {base: 'fa', font: ''}; + if (!this.$media.is('span, i')) { + var $span = $('<span/>'); + $span.data(this.$media.data()); + this.$media = $span; + this.media = this.$media[0]; + style = style.replace(/\s*width:[^;]+/, ''); + } + this.$media.removeClass(this.initialIcon).addClass([iconFont.base, iconFont.font]); + this.$media.attr('style', style || null); + return Promise.resolve(this.media); + }, + /** + * @override + */ + search: function (needle) { + var iconsParser = this.iconsParser; + if (needle && needle.length) { + iconsParser = []; + _.filter(this.iconsParser, function (data) { + var cssData = _.filter(data.cssData, function (cssData) { + return _.find(cssData.names, function (alias) { + return alias.indexOf(needle) >= 0; + }); + }); + if (cssData.length) { + iconsParser.push({ + base: data.base, + cssData: cssData, + }); + } + }); + } + this.$('div.font-icons-icons').html( + QWeb.render('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser, widget: this}) + ); + return Promise.resolve(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _clear: function () { + var allFaClasses = /(^|\s)(fa|(text-|bg-|fa-)\S*|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g; + this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' '); + }, + /** + * @private + */ + _getFont: function (classNames) { + if (!(classNames instanceof Array)) { + classNames = (classNames || "").split(/\s+/); + } + var fontIcon, cssData; + for (var k = 0; k < this.iconsParser.length; k++) { + fontIcon = this.iconsParser[k]; + for (var s = 0; s < fontIcon.cssData.length; s++) { + cssData = fontIcon.cssData[s]; + if (_.intersection(classNames, cssData.names).length) { + return { + base: fontIcon.base, + parser: fontIcon.parser, + font: cssData.names[0], + }; + } + } + } + return null; + }, + /** + * @private + */ + _highlightSelectedIcon: function () { + var self = this; + this.$icons.removeClass('o_we_attachment_selected'); + this.$icons.filter(function (i, el) { + return _.contains($(el).data('alias').split(','), self.selectedIcon); + }).addClass('o_we_attachment_selected'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onIconClick: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + this.selectedIcon = $(ev.currentTarget).data('id'); + this._highlightSelectedIcon(); + this.trigger_up('save_request'); + }, +}); + +/** + * Let users choose a video, support all summernote video, and embed iframe. + */ +var VideoWidget = MediaWidget.extend({ + template: 'wysiwyg.widgets.video', + events: _.extend({}, MediaWidget.prototype.events || {}, { + 'change .o_video_dialog_options input': '_onUpdateVideoOption', + 'input textarea#o_video_text': '_onVideoCodeInput', + 'change textarea#o_video_text': '_onVideoCodeChange', + }), + + /** + * @constructor + */ + init: function (parent, media, options) { + this._super.apply(this, arguments); + this.isForBgVideo = !!options.isForBgVideo; + this._onVideoCodeInput = _.debounce(this._onVideoCodeInput, 1000); + }, + /** + * @override + */ + start: function () { + this.$content = this.$('.o_video_dialog_iframe'); + + if (this.media) { + var $media = $(this.media); + var src = $media.data('oe-expression') || $media.data('src') || ($media.is('iframe') ? $media.attr('src') : '') || ''; + this.$('textarea#o_video_text').val(src); + + this.$('input#o_video_autoplay').prop('checked', src.indexOf('autoplay=1') >= 0); + this.$('input#o_video_hide_controls').prop('checked', src.indexOf('controls=0') >= 0); + this.$('input#o_video_loop').prop('checked', src.indexOf('loop=1') >= 0); + this.$('input#o_video_hide_fullscreen').prop('checked', src.indexOf('fs=0') >= 0); + this.$('input#o_video_hide_yt_logo').prop('checked', src.indexOf('modestbranding=1') >= 0); + this.$('input#o_video_hide_dm_logo').prop('checked', src.indexOf('ui-logo=0') >= 0); + this.$('input#o_video_hide_dm_share').prop('checked', src.indexOf('sharing-enable=0') >= 0); + + this._updateVideo(); + } + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + this._updateVideo(); + if (this.isForBgVideo) { + return Promise.resolve({bgVideoSrc: this.$content.attr('src')}); + } + if (this.$('.o_video_dialog_iframe').is('iframe')) { + this.$media = $( + '<div class="media_iframe_video" data-oe-expression="' + this.$content.attr('src') + '">' + + '<div class="css_editable_mode_display"> </div>' + + '<div class="media_iframe_video_size" contenteditable="false"> </div>' + + '<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"></iframe>' + + '</div>' + ); + this.media = this.$media[0]; + } + return Promise.resolve(this.media); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _clear: function () { + if (this.media.dataset.src) { + try { + delete this.media.dataset.src; + } catch (e) { + this.media.dataset.src = undefined; + } + } + var allVideoClasses = /(^|\s)media_iframe_video(\s|$)/g; + var isVideo = this.media.className && this.media.className.match(allVideoClasses); + if (isVideo) { + this.media.className = this.media.className.replace(allVideoClasses, ' '); + this.media.innerHTML = ''; + } + }, + /** + * Creates a video node according to the given URL and options. If not + * possible, returns an error code. + * + * @private + * @param {string} url + * @param {Object} options + * @returns {Object} + * $video -> the created video jQuery node + * type -> the type of the created video + * errorCode -> if defined, either '0' for invalid URL or '1' for + * unsupported video provider + */ + _createVideoNode: function (url, options) { + options = options || {}; + const videoData = this._getVideoURLData(url, options); + if (videoData.error) { + return {errorCode: 0}; + } + if (!videoData.type) { + return {errorCode: 1}; + } + const $video = $('<iframe>').width(1280).height(720) + .attr('frameborder', 0) + .attr('src', videoData.embedURL) + .addClass('o_video_dialog_iframe'); + + return {$video: $video, type: videoData.type}; + }, + /** + * Updates the video preview according to video code and enabled options. + * + * @private + */ + _updateVideo: function () { + // Reset the feedback + this.$content.empty(); + this.$('#o_video_form_group').removeClass('o_has_error o_has_success').find('.form-control, .custom-select').removeClass('is-invalid is-valid'); + this.$('.o_video_dialog_options div').addClass('d-none'); + + // Check video code + var $textarea = this.$('textarea#o_video_text'); + var code = $textarea.val().trim(); + if (!code) { + return; + } + + // Detect if we have an embed code rather than an URL + var embedMatch = code.match(/(src|href)=["']?([^"']+)?/); + if (embedMatch && embedMatch[2].length > 0 && embedMatch[2].indexOf('instagram')) { + embedMatch[1] = embedMatch[2]; // Instagram embed code is different + } + var url = embedMatch ? embedMatch[1] : code; + + var query = this._createVideoNode(url, { + 'autoplay': this.isForBgVideo || this.$('input#o_video_autoplay').is(':checked'), + 'hide_controls': this.isForBgVideo || this.$('input#o_video_hide_controls').is(':checked'), + 'loop': this.isForBgVideo || this.$('input#o_video_loop').is(':checked'), + 'hide_fullscreen': this.isForBgVideo || this.$('input#o_video_hide_fullscreen').is(':checked'), + 'hide_yt_logo': this.isForBgVideo || this.$('input#o_video_hide_yt_logo').is(':checked'), + 'hide_dm_logo': this.isForBgVideo || this.$('input#o_video_hide_dm_logo').is(':checked'), + 'hide_dm_share': this.isForBgVideo || this.$('input#o_video_hide_dm_share').is(':checked'), + }); + + var $optBox = this.$('.o_video_dialog_options'); + + // Show / Hide preview elements + this.$el.find('.o_video_dialog_preview_text, .media_iframe_video_size').add($optBox).toggleClass('d-none', !query.$video); + // Toggle validation classes + this.$el.find('#o_video_form_group') + .toggleClass('o_has_error', !query.$video).find('.form-control, .custom-select').toggleClass('is-invalid', !query.$video) + .end() + .toggleClass('o_has_success', !!query.$video).find('.form-control, .custom-select').toggleClass('is-valid', !!query.$video); + + // Individually show / hide options base on the video provider + $optBox.find('div.o_' + query.type + '_option').removeClass('d-none'); + + // Hide the entire options box if no options are available or if the + // dialog is opened for a background-video + $optBox.toggleClass('d-none', this.isForBgVideo || $optBox.find('div:not(.d-none)').length === 0); + + if (query.type === 'youtube') { + // Youtube only: If 'hide controls' is checked, hide 'fullscreen' + // and 'youtube logo' options too + this.$('input#o_video_hide_fullscreen, input#o_video_hide_yt_logo').closest('div').toggleClass('d-none', this.$('input#o_video_hide_controls').is(':checked')); + } + + var $content = query.$video; + if (!$content) { + switch (query.errorCode) { + case 0: + $content = $('<div/>', { + class: 'alert alert-danger o_video_dialog_iframe mb-2 mt-2', + text: _t("The provided url is not valid"), + }); + break; + case 1: + $content = $('<div/>', { + class: 'alert alert-warning o_video_dialog_iframe mb-2 mt-2', + text: _t("The provided url does not reference any supported video"), + }); + break; + } + } + this.$content.replaceWith($content); + this.$content = $content; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a video option changes -> Updates the video preview. + * + * @private + */ + _onUpdateVideoOption: function () { + this._updateVideo(); + }, + /** + * Called when the video code (URL / Iframe) change is confirmed -> Updates + * the video preview immediately. + * + * @private + */ + _onVideoCodeChange: function () { + this._updateVideo(); + }, + /** + * Called when the video code (URL / Iframe) changes -> Updates the video + * preview (note: this function is automatically debounced). + * + * @private + */ + _onVideoCodeInput: function () { + this._updateVideo(); + }, + /** + * Parses a URL and returns the provider type and an emebedable URL. + * + * @private + */ + _getVideoURLData: function (url, options) { + if (!url.match(/^(http:\/\/|https:\/\/|\/\/)[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i)) { + return { + error: true, + message: 'The provided url is invalid', + }; + } + const regexes = { + youtube: /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/, + instagram: /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/, + vine: /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/, + vimeo: /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/, + dailymotion: /.+dailymotion.com\/(video|hub|embed)\/([^_?]+)[^#]*(#video=([^_&]+))?/, + youku: /(.*).youku\.com\/(v_show\/id_|embed\/)(.+)/, + }; + const matches = _.mapObject(regexes, regex => url.match(regex)); + const autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0'; + const controls = options.hide_controls ? '&controls=0' : ''; + const loop = options.loop ? '&loop=1' : ''; + + let embedURL; + let type; + if (matches.youtube && matches.youtube[2].length === 11) { + const fullscreen = options.hide_fullscreen ? '&fs=0' : ''; + const ytLoop = loop ? loop + `&playlist=${matches.youtube[2]}` : ''; + const logo = options.hide_yt_logo ? '&modestbranding=1' : ''; + embedURL = `//www.youtube${matches.youtube[1] || ''}.com/embed/${matches.youtube[2]}${autoplay}&rel=0${ytLoop}${controls}${fullscreen}${logo}`; + type = 'youtube'; + } else if (matches.instagram && matches.instagram[2].length) { + embedURL = `//www.instagram.com/p/${matches.instagram[2]}/embed/`; + type = 'instagram'; + } else if (matches.vine && matches.vine[0].length) { + embedURL = `${matches.vine[0]}/embed/simple`; + type = 'vine'; + } else if (matches.vimeo && matches.vimeo[3].length) { + const vimeoAutoplay = autoplay.replace('mute', 'muted'); + embedURL = `//player.vimeo.com/video/${matches.vimeo[3]}${vimeoAutoplay}${loop}`; + type = 'vimeo'; + } else if (matches.dailymotion && matches.dailymotion[2].length) { + const videoId = matches.dailymotion[2].replace('video/', ''); + const logo = options.hide_dm_logo ? '&ui-logo=0' : ''; + const share = options.hide_dm_share ? '&sharing-enable=0' : ''; + embedURL = `//www.dailymotion.com/embed/video/${videoId}${autoplay}${controls}${logo}${share}`; + type = 'dailymotion'; + } else if (matches.youku && matches.youku[3].length) { + const videoId = matches.youku[3].indexOf('.html?') >= 0 ? matches.youku[3].substring(0, matches.youku[3].indexOf('.html?')) : matches.youku[3]; + embedURL = `//player.youku.com/embed/${videoId}`; + type = 'youku'; + } + + return {type: type, embedURL: embedURL}; + }, +}); + +return { + MediaWidget: MediaWidget, + SearchableMediaWidget: SearchableMediaWidget, + FileWidget: FileWidget, + ImageWidget: ImageWidget, + DocumentWidget: DocumentWidget, + IconWidget: IconWidget, + VideoWidget: VideoWidget, +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js new file mode 100644 index 00000000..0832aa45 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js @@ -0,0 +1,279 @@ +odoo.define('wysiwyg.widgets.MediaDialog', function (require) { +'use strict'; + +var core = require('web.core'); +var MediaModules = require('wysiwyg.widgets.media'); +var Dialog = require('wysiwyg.widgets.Dialog'); + +var _t = core._t; + +/** + * Lets the user select a media. The media can be existing or newly uploaded. + * + * The media can be one of the following types: image, document, video or + * font awesome icon (only existing icons). + * + * The user may change a media into another one depending on the given options. + */ +var MediaDialog = Dialog.extend({ + template: 'wysiwyg.widgets.media', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web_editor/static/src/xml/wysiwyg.xml'] + ), + events: _.extend({}, Dialog.prototype.events, { + 'click #editor-media-image-tab': '_onClickImageTab', + 'click #editor-media-document-tab': '_onClickDocumentTab', + 'click #editor-media-icon-tab': '_onClickIconTab', + 'click #editor-media-video-tab': '_onClickVideoTab', + }), + custom_events: _.extend({}, Dialog.prototype.custom_events || {}, { + save_request: '_onSaveRequest', + show_parent_dialog_request: '_onShowRequest', + hide_parent_dialog_request: '_onHideRequest', + }), + + /** + * @constructor + * @param {Element} media + */ + init: function (parent, options, media) { + var $media = $(media); + media = $media[0]; + this.media = media; + + options = _.extend({}, options); + var onlyImages = options.onlyImages || this.multiImages || (media && ($media.parent().data('oeField') === 'image' || $media.parent().data('oeType') === 'image')); + options.noDocuments = onlyImages || options.noDocuments; + options.noIcons = onlyImages || options.noIcons; + options.noVideos = onlyImages || options.noVideos; + + this._super(parent, _.extend({}, { + title: _t("Select a Media"), + save_text: _t("Add"), + }, options)); + + this.trigger_up('getRecordInfo', { + recordInfo: options, + type: 'media', + callback: function (recordInfo) { + _.defaults(options, recordInfo); + }, + }); + + if (!options.noImages) { + this.imageWidget = new MediaModules.ImageWidget(this, media, options); + } + if (!options.noDocuments) { + this.documentWidget = new MediaModules.DocumentWidget(this, media, options); + } + if (!options.noIcons) { + this.iconWidget = new MediaModules.IconWidget(this, media, options); + } + if (!options.noVideos) { + this.videoWidget = new MediaModules.VideoWidget(this, media, options); + } + + if (this.imageWidget && $media.is('img')) { + this.activeWidget = this.imageWidget; + } else if (this.documentWidget && $media.is('a.o_image')) { + this.activeWidget = this.documentWidget; + } else if (this.videoWidget && $media.is('.media_iframe_video, .o_bg_video_iframe')) { + this.activeWidget = this.videoWidget; + } else if (this.iconWidget && $media.is('span, i')) { + this.activeWidget = this.iconWidget; + } else { + this.activeWidget = [this.imageWidget, this.documentWidget, this.videoWidget, this.iconWidget].find(w => !!w); + } + this.initiallyActiveWidget = this.activeWidget; + }, + /** + * Adds the appropriate class to the current modal and appends the media + * widgets to their respective tabs. + * + * @override + */ + start: function () { + var promises = [this._super.apply(this, arguments)]; + this.$modal.find('.modal-dialog').addClass('o_select_media_dialog'); + + if (this.imageWidget) { + promises.push(this.imageWidget.appendTo(this.$("#editor-media-image"))); + } + if (this.documentWidget) { + promises.push(this.documentWidget.appendTo(this.$("#editor-media-document"))); + } + if (this.iconWidget) { + promises.push(this.iconWidget.appendTo(this.$("#editor-media-icon"))); + } + if (this.videoWidget) { + promises.push(this.videoWidget.appendTo(this.$("#editor-media-video"))); + } + + this.opened(() => this.$('input.o_we_search:visible:first').focus()); + + return Promise.all(promises); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the document widget is currently active. + * + * @returns {boolean} + */ + isDocumentActive: function () { + return this.activeWidget === this.documentWidget; + }, + /** + * Returns whether the icon widget is currently active. + * + * @returns {boolean} + */ + isIconActive: function () { + return this.activeWidget === this.iconWidget; + }, + /** + * Returns whether the image widget is currently active. + * + * @returns {boolean} + */ + isImageActive: function () { + return this.activeWidget === this.imageWidget; + }, + /** + * Returns whether the video widget is currently active. + * + * @returns {boolean} + */ + isVideoActive: function () { + return this.activeWidget === this.videoWidget; + }, + /** + * Saves the currently selected media from the currently active widget. + * + * The save event data `final_data` will be one Element in general, but it + * will be an Array of Element if `multiImages` is set. + * + * @override + */ + save: function () { + var self = this; + var _super = this._super; + var args = arguments; + return this.activeWidget.save().then(function (data) { + if (self.activeWidget !== self.initiallyActiveWidget) { + self._clearWidgets(); + } + // Restore classes if the media was replaced (when changing type) + if (self.media !== data) { + var oldClasses = self.media && _.toArray(self.media.classList); + if (oldClasses) { + data.className = _.union(_.toArray(data.classList), oldClasses).join(' '); + } + } + self.final_data = data; + _super.apply(self, args); + $(data).trigger('content_changed'); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Call clear on all the widgets except the activeWidget. + * We clear because every widgets are modifying the "media" element. + * All widget have the responsibility to clear a previous element that + * was created from them. + */ + _clearWidgets: function () { + [ this.imageWidget, + this.documentWidget, + this.iconWidget, + this.videoWidget + ].forEach( (widget) => { + if (widget !== this.activeWidget) { + widget && widget.clear(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Sets the document widget as the active widget. + * + * @private + */ + _onClickDocumentTab: function () { + this.activeWidget = this.documentWidget; + }, + /** + * Sets the icon widget as the active widget. + * + * @private + */ + _onClickIconTab: function () { + this.activeWidget = this.iconWidget; + }, + /** + * Sets the image widget as the active widget. + * + * @private + */ + _onClickImageTab: function () { + this.activeWidget = this.imageWidget; + }, + /** + * Sets the video widget as the active widget. + * + * @private + */ + _onClickVideoTab: function () { + this.activeWidget = this.videoWidget; + }, + /** + * Handles hide request from child widgets. + * + * This is for usability, to allow hiding the modal for example when another + * smaller modal would be displayed on top. + * + * @private + * @param {OdooEvent} ev + */ + _onHideRequest: function (ev) { + this.$modal.addClass('d-none'); + }, + /** + * Handles save request from the child widgets. + * + * This is for usability, to allow the user to save from other ways than + * click on the modal button, such as double clicking a media to select it. + * + * @private + * @param {OdooEvent} ev + */ + _onSaveRequest: function (ev) { + ev.stopPropagation(); + this.save(); + }, + /** + * Handles show request from the child widgets. + * + * This is for usability, it is the counterpart of @see _onHideRequest. + * + * @private + * @param {OdooEvent} ev + */ + _onShowRequest: function (ev) { + this.$modal.removeClass('d-none'); + }, +}); + +return MediaDialog; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js new file mode 100644 index 00000000..64a9dc06 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js @@ -0,0 +1,29 @@ +odoo.define('wysiwyg.widgets', function (require) { +'use strict'; + +var Dialog = require('wysiwyg.widgets.Dialog'); +var AltDialog = require('wysiwyg.widgets.AltDialog'); +var MediaDialog = require('wysiwyg.widgets.MediaDialog'); +var LinkDialog = require('wysiwyg.widgets.LinkDialog'); +var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget'); +const {ColorpickerDialog} = require('web.Colorpicker'); + +var media = require('wysiwyg.widgets.media'); + +return { + Dialog: Dialog, + AltDialog: AltDialog, + MediaDialog: MediaDialog, + LinkDialog: LinkDialog, + ImageCropWidget: ImageCropWidget, + ColorpickerDialog: ColorpickerDialog, + + MediaWidget: media.MediaWidget, + SearchableMediaWidget: media.SearchableMediaWidget, + FileWidget: media.FileWidget, + ImageWidget: media.ImageWidget, + DocumentWidget: media.DocumentWidget, + IconWidget: media.IconWidget, + VideoWidget: media.VideoWidget, +}; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js new file mode 100644 index 00000000..6a1924e1 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -0,0 +1,274 @@ +odoo.define('web_editor.wysiwyg', function (require) { +'use strict'; +var Widget = require('web.Widget'); +var SummernoteManager = require('web_editor.rte.summernote'); +var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors'); +var id = 0; + +// core.bus +// media_dialog_demand +var Wysiwyg = Widget.extend({ + xmlDependencies: [ + ], + defaultOptions: { + 'focus': false, + 'toolbar': [ + ['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', + 'colors': summernoteCustomColors, + recordInfo: { + context: {}, + }, + }, + /** + * @options {Object} options + * @options {Object} options.recordInfo + * @options {Object} options.recordInfo.context + * @options {String} [options.recordInfo.context] + * @options {integer} [options.recordInfo.res_id] + * @options {String} [options.recordInfo.data_res_model] + * @options {integer} [options.recordInfo.data_res_id] + * @see _onGetRecordInfo + * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js + * @options {Object} options.attachments + * @see _onGetRecordInfo + * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js (for attachmentIDs) + * @options {function} options.generateOptions + * called with the summernote configuration object used before sending to summernote + * @see _editorOptions + **/ + init: function (parent, options) { + this._super.apply(this, arguments); + this.id = ++id; + this.options = options; + }, + /** + * Load assets and color picker template then call summernote API + * and replace $el by the summernote editable node. + * + * @override + **/ + willStart: function () { + this._summernoteManager = new SummernoteManager(this); + this.$target = this.$el; + return this._super(); + }, + /** + * + * @override + */ + start: function () { + this.$target.wrap('<odoo-wysiwyg-container>'); + this.$el = this.$target.parent(); + var options = this._editorOptions(); + this.$target.summernote(options); + this.$editor = this.$('.note-editable:first'); + this.$editor.data('wysiwyg', this); + this.$editor.data('oe-model', options.recordInfo.res_model); + this.$editor.data('oe-id', options.recordInfo.res_id); + $(document).on('mousedown', this._blur); + this._value = this.$target.html() || this.$target.val(); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + $(document).off('mousedown', this._blur); + if (this.$target && this.$target.is('textarea') && this.$target.next('.note-editor').length) { + this.$target.summernote('destroy'); + } + this._super(); + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Return the editable area. + * + * @returns {jQuery} + */ + getEditable: function () { + return this.$editor; + }, + /** + * Return true if the content has changed. + * + * @returns {Boolean} + */ + isDirty: function () { + return this._value !== (this.$editor.html() || this.$editor.val()); + }, + /** + * Set the focus on the element. + */ + focus: function () { + console.log('focus'); + }, + /** + * Get the value of the editable element. + * + * @param {object} [options] + * @param {jQueryElement} [options.$layout] + * @returns {String} + */ + getValue: function (options) { + var $editable = options && options.$layout || this.$editor.clone(); + $editable.find('[contenteditable]').removeAttr('contenteditable'); + $editable.find('[class=""]').removeAttr('class'); + $editable.find('[style=""]').removeAttr('style'); + $editable.find('[title=""]').removeAttr('title'); + $editable.find('[alt=""]').removeAttr('alt'); + $editable.find('[data-original-title=""]').removeAttr('data-original-title'); + if (!options || !options['style-inline']) { + $editable.find('a.o_image, span.fa, i.fa').html(''); + } + $editable.find('[aria-describedby]').removeAttr('aria-describedby').removeAttr('data-original-title'); + return $editable.html(); + }, + /** + * Save the content in the target + * - in init option beforeSave + * - receive editable jQuery DOM as attribute + * - called after deactivate codeview if needed + * @returns {Promise} + * - resolve with true if the content was dirty + */ + save: function (options) { + var isDirty = this.isDirty(); + var html = this.getValue(options); + if (this.$target.is('textarea')) { + this.$target.val(html); + } else { + this.$target.html(html); + } + return Promise.resolve({isDirty:isDirty, html:html}); + }, + /** + * Create/Update cropped attachments. + * + * @param {jQuery} $editable + * @returns {Promise} + */ + saveModifiedImages: function ($editable) { + return this._summernoteManager.saveModifiedImages($editable); + }, + /** + * @param {String} value + * @param {Object} options + * @param {Boolean} [options.notifyChange] + * @returns {String} + */ + setValue: function (value, options) { + if (this.$editor.is('textarea')) { + this.$target.val(value); + } else { + this.$target.html(value); + } + this.$editor.html(value); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + _editorOptions: function () { + var self = this; + var options = Object.assign({}, $.summernote.options, this.defaultOptions, this.options); + if (this.options.generateOptions) { + options = this.options.generateOptions(options); + } + options.airPopover = options.toolbar; + options.onChange = function (html, $editable) { + $editable.trigger('content_changed'); + self.trigger_up('wysiwyg_change'); + }; + options.onUpload = function (attachments) { + self.trigger_up('wysiwyg_attachment', attachments); + }; + options.onFocus = function () { + self.trigger_up('wysiwyg_focus'); + }; + options.onBlur = function () { + self.trigger_up('wysiwyg_blur'); + }; + return options; + }, +}); +//-------------------------------------------------------------------------- +// Public helper +//-------------------------------------------------------------------------- +/** + * @param {Node} node (editable or node inside) + * @returns {Object} + * @returns {Node} sc - start container + * @returns {Number} so - start offset + * @returns {Node} ec - end container + * @returns {Number} eo - end offset + */ +Wysiwyg.getRange = function (node) { + var range = $.summernote.core.range.create(); + return range && { + sc: range.sc, + so: range.so, + ec: range.ec, + eo: range.eo, + }; +}; +/** + * @param {Node} startNode + * @param {Number} startOffset + * @param {Node} endNode + * @param {Number} endOffset + */ +Wysiwyg.setRange = function (startNode, startOffset, endNode, endOffset) { + $(startNode).focus(); + if (endNode) { + $.summernote.core.range.create(startNode, startOffset, endNode, endOffset).select(); + } else { + $.summernote.core.range.create(startNode, startOffset).select(); + } + // trigger for Unbreakable + $(startNode.tagName ? startNode : startNode.parentNode).trigger('wysiwyg.range'); +}; +/** + * @param {Node} node - dom node + * @param {Object} [options] + * @param {Boolean} options.begin move the range to the beginning of the first node. + * @param {Boolean} options.end move the range to the end of the last node. + */ +Wysiwyg.setRangeFromNode = function (node, options) { + var last = node; + while (last.lastChild) { + last = last.lastChild; + } + var first = node; + while (first.firstChild) { + first = first.firstChild; + } + if (options && options.begin && !options.end) { + Wysiwyg.setRange(first, 0); + } else if (options && !options.begin && options.end) { + Wysiwyg.setRange(last, last.textContent.length); + } else { + Wysiwyg.setRange(first, 0, last, last.tagName ? last.childNodes.length : last.textContent.length); + } +}; +return Wysiwyg; +}); +odoo.define('web_editor.widget', function (require) { +'use strict'; + return { + Dialog: require('wysiwyg.widgets.Dialog'), + MediaDialog: require('wysiwyg.widgets.MediaDialog'), + LinkDialog: require('wysiwyg.widgets.LinkDialog'), + }; +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js new file mode 100644 index 00000000..f56f5b26 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js @@ -0,0 +1,132 @@ +odoo.define('web_editor.wysiwyg.iframe', function (require) { +'use strict'; + +var Wysiwyg = require('web_editor.wysiwyg'); +var ajax = require('web.ajax'); +var core = require('web.core'); +var config = require('web.config'); + +var qweb = core.qweb; +var promiseCommon; +var promiseWysiwyg; + + +/** + * Add option (inIframe) to load Wysiwyg in an iframe. + **/ +Wysiwyg.include({ + /** + * Add options to load Wysiwyg in an iframe. + * + * @override + * @param {boolean} options.inIframe + **/ + init: function (parent, options) { + this._super.apply(this, arguments); + if (this.options.inIframe) { + this._onUpdateIframeId = 'onLoad_' + this.id; + } + this.__extraAssetsForIframe = []; + }, + /** + * Load assets to inject into iframe. + * + * @override + **/ + willStart: function () { + if (!this.options.inIframe) { + return this._super(); + } + + var defAsset; + if (this.options.iframeCssAssets) { + defAsset = ajax.loadAsset(this.options.iframeCssAssets); + } else { + defAsset = Promise.resolve({ + cssLibs: [], + cssContents: [] + }); + } + + promiseWysiwyg = promiseWysiwyg || ajax.loadAsset('web_editor.wysiwyg_iframe_editor_assets'); + this.defAsset = Promise.all([promiseWysiwyg, defAsset]); + + this.$target = this.$el; + return this.defAsset + .then(this._loadIframe.bind(this)) + .then(this._super.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Create iframe, inject css and create a link with the content, + * then inject the target inside. + * + * @private + * @returns {Promise} + */ + _loadIframe: function () { + var self = this; + this.$iframe = $('<iframe class="wysiwyg_iframe">').css({ + 'min-height': '55vh', + width: '100%' + }); + var avoidDoubleLoad = 0; // this bug only appears on some configurations. + + // resolve promise on load + var def = new Promise(function (resolve) { + window.top[self._onUpdateIframeId] = function (Editor, _avoidDoubleLoad) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg iframe double load detected'); + return; + } + delete window.top[self._onUpdateIframeId]; + var $iframeTarget = self.$iframe.contents().find('#iframe_target'); + $iframeTarget.attr("isMobile", config.device.isMobile); + $iframeTarget.find('.o_editable').html(self.$target.val()); + self.options.toolbarHandler = $('#web_editor-top-edit', self.$iframe[0].contentWindow.document); + $(qweb.render('web_editor.FieldTextHtml.fullscreen')) + .appendTo(self.options.toolbarHandler) + .on('click', '.o_fullscreen', function () { + $("body").toggleClass("o_field_widgetTextHtml_fullscreen"); + var full = $("body").hasClass("o_field_widgetTextHtml_fullscreen"); + self.$iframe.parents().toggleClass('o_form_fullscreen_ancestor', full); + $(window).trigger("resize"); // induce a resize() call and let other backend elements know (the navbar extra items management relies on this) + }); + self.Editor = Editor; + resolve(); + }; + }); + this.$iframe.data('loadDef', def); // for unit test + + // inject content in iframe + + this.$iframe.on('load', function onLoad (ev) { + var _avoidDoubleLoad = ++avoidDoubleLoad; + self.defAsset.then(function (assets) { + if (_avoidDoubleLoad !== avoidDoubleLoad) { + console.warn('Wysiwyg immediate iframe double load detected'); + return; + } + + var iframeContent = qweb.render('wysiwyg.iframeContent', { + assets: assets.concat(self.__extraAssetsForIframe), + updateIframeId: self._onUpdateIframeId, + avoidDoubleLoad: _avoidDoubleLoad + }); + self.$iframe[0].contentWindow.document + .open("text/html", "replace") + .write(iframeContent); + }); + }); + + this.$iframe.insertAfter(this.$target); + + return def; + }, +}); + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js new file mode 100644 index 00000000..73801dc9 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js @@ -0,0 +1,56 @@ +odoo.define('web_editor.wysiwyg.snippets', function (require) { +'use strict'; +var editor = require('web_editor.editor'); +var Wysiwyg = require('web_editor.wysiwyg'); + + +Wysiwyg.include({ + init: function (parent, options) { + this._super.apply(this, arguments); + this.Editor = editor.Class; + if (!this.options.toolbarHandler) { + this.options.toolbarHandler = $('#web_editor-top-edit'); + } + }, + start: async function () { + if (this.options.snippets) { + var self = this; + this.editor = new (this.Editor)(this, this.options); + this.$editor = this.editor.rte.editable(); + const $body = this.$editor[0] ? this.$editor[0].ownerDocument.body : document.body; + await this.editor.prependTo($body); + this._relocateEditorBar(); + this.$el.on('content_changed', function (e) { + self.trigger_up('wysiwyg_change'); + }); + } else { + return this._super(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _relocateEditorBar: function () { + if (!this.options.toolbarHandler.length) { + this.options.toolbarHandler = $('.o_we_snippet_text_tools'); + } + this.options.toolbarHandler.append(this.editor.$el); + + // TODO the next four lines are a huge hack: since the editor.$el + // is repositioned, the snippetsMenu elements are not at the + // correct position anymore if it was repositioned outside of it... + // the whole logic has to be refactored... hopefully not needed anymore + // with editor team changes + if (this.editor.snippetsMenu && !this.editor.snippetsMenu.$el.has(this.options.toolbarHandler).length) { + this.editor.snippetsMenu.$el.insertAfter(this.options.toolbarHandler); + this.editor.snippetsMenu.$snippetEditorArea.insertAfter(this.editor.snippetsMenu.$el); + } + }, +}); + +}); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js |
