From de444335b7da30a2ab3a4c5b92c9731a023f3f07 Mon Sep 17 00:00:00 2001 From: Christophe Matthieu Date: Fri, 28 Dec 2018 17:49:21 +0000 Subject: [REF] web_editor,*: Move and rename files to prepare refactoring The refactoring of the wysiwyg editor and 'html' field allows us to move some of the code that was in web_editor but only used in website or in mass_mailing. Some parts are still in web_editor but will be moved at a later time. --- .../static/src/js/backend/convert_inline.js | 395 +++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 addons/web_editor/static/src/js/backend/convert_inline.js (limited to 'addons/web_editor/static/src/js/backend/convert_inline.js') 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 00000000000..85903f6b4eb --- /dev/null +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -0,0 +1,395 @@ +odoo.define('web_editor.transcoder', function (require) { +'use strict'; + +var base = require('web_editor.base'); + +var rulesCache = []; + +/** + * Returns the css rules which applies on an element, tweaked so that they are + * browser/mail client ok. + * + * @param {DOMElement} a + * @returns {Object} css property name -> css property value + */ +function getMatchedCSSRules(a) { + var i, r, k; + if (!rulesCache.length) { + var sheets = document.styleSheets; + for (i = sheets.length-1 ; i >= 0 ; i--) { + var rules; + // try...catch because browser may not able to enumerate rules for cross-domain sheets + try { + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (rules) { + for (r = rules.length-1; r >= 0; r--) { + var selectorText = rules[r].selectorText; + if (selectorText && + rules[r].cssText && + selectorText !== '*' && + selectorText.indexOf(':hover') === -1 && + selectorText.indexOf(':before') === -1 && + selectorText.indexOf(':after') === -1 && + selectorText.indexOf(':active') === -1 && + selectorText.indexOf(':link') === -1 && + selectorText.indexOf('::') === -1 && + selectorText.indexOf("'") === -1) { + var st = selectorText.split(/\s*,\s*/); + for (k = 0 ; k < st.length ; k++) { + rulesCache.push({ 'selector': st[k], 'style': rules[r].style }); + } + } + } + } + } + rulesCache.reverse(); + } + + var css = []; + var style; + a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector; + for (r = 0; r < rulesCache.length; r++) { + if (a.matches(rulesCache[r].selector)) { + style = rulesCache[r].style; + if (style.parentRule) { + var style_obj = {}; + var len; + for (k = 0, len = style.length ; k < len ; k++) { + if (style[k].indexOf('animation') !== -1) { + continue; + } + style_obj[style[k]] = style[style[k].replace(/-(.)/g, function (a, b) { return b.toUpperCase(); })]; + if (new RegExp(style[k] + '\s*:[^:;]+!important' ).test(style.cssText)) { + style_obj[style[k]] += ' !important'; + } + } + rulesCache[r].style = style = style_obj; + } + css.push([rulesCache[r].selector, style]); + } + } + + function specificity(selector) { + // http://www.w3.org/TR/css3-selectors/#specificity + var a = 0; + selector = selector.replace(/#[a-z0-9_-]+/gi, function () { a++; return ''; }); + var b = 0; + selector = selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ''; }); + var c = 0; + selector = selector.replace(/(^|\s+|:+)[a-z0-9_-]+/gi, function (a) { if (a.indexOf(':not(')===-1) c++; return ''; }); + return a*100 + b*10 + c; + } + css.sort(function (a, b) { return specificity(a[0]) - specificity(b[0]); }); + + style = {}; + _.each(css, function (v,k) { + _.each(v[1], function (v,k) { + if (v && _.isString(v) && k.indexOf('-webkit') === -1 && (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1)) { + style[k] = v; + } + }); + }); + + _.each(style, function (v,k) { + if (v.indexOf('important') !== -1) { + style[k] = v.slice(0, v.length-11); + } + }); + + if (style.display === 'block') { + delete style.display; + } + + // The css generates all the attributes separately and not in simplified form. + // In order to have a better compatibility (outlook for example) we simplify the css tags. + // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none + _.each([ + {property: 'margin'}, + {property: 'padding'}, + {property: 'border', propertyEnd: '-style', defaultValue: 'none'}, + ], function (propertyInfo) { + var p = propertyInfo.property; + var e = propertyInfo.propertyEnd || ''; + var defVal = propertyInfo.defaultValue || 0; + + if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) { + if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) { + // keep => property: [top/right/bottom/left value]; + style[p+e] = style[p+'-top'+e]; + } + else { + // keep => property: [top value] [right value] [bottom value] [left value]; + style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal); + if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) { + // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value]; + delete style[p+e]; + return; + } + } + delete style[p+'-top'+e]; + delete style[p+'-right'+e]; + delete style[p+'-bottom'+e]; + delete style[p+'-left'+e]; + } + }); + + if (style['border-bottom-left-radius']) { + style['border-radius'] = style['border-bottom-left-radius']; + delete style['border-bottom-left-radius']; + delete style['border-bottom-right-radius']; + delete style['border-top-left-radius']; + delete style['border-top-right-radius']; + } + + // if the border styling is initial we remove it to simplify the css tags for compatibility. + // Also, since we do not send a css style tag, the initial value of the border is useless. + _.each(_.keys(style), function (k) { + if (k.indexOf('border') !== -1 && style[k] === 'initial') { + delete style[k]; + } + }); + + // text-decoration rule is decomposed in -line, -color and -style. This is + // however not supported by many browser/mail clients and the editor does + // not allow to change -color and -style rule anyway + if (style['text-decoration-line']) { + style['text-decoration'] = style['text-decoration-line']; + delete style['text-decoration-line']; + delete style['text-decoration-color']; + delete style['text-decoration-style']; + } + + // text-align inheritance does not seem to get past 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.is('html')); + } + + return style; +} + +/** + * Converts font icons to images. + * + * @param {jQuery} $editable - the element in which the font icons have to be + * converted to images + */ +function fontToImg($editable) { + $editable.find('.fa').each(function () { + var $font = $(this); + var icon, content; + _.find(base.fontIcons, function (font) { + return _.find(base.getCssSelectors(font.parser), function (data) { + if ($font.is(data.selector.replace(/::?before/g, ''))) { + icon = data.names[0].split('-').shift(); + content = data.css.match(/content:\s*['"]?(.)['"]?/)[1]; + return true; + } + }); + }); + if (content) { + var color = $font.css('color').replace(/\s/g, ''); + $font.replaceWith($('', { + src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, $font.height())), + 'data-class': $font.attr('class'), + 'data-style': $font.attr('style'), + class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style + style: $font.attr('style'), + }).css({height: 'auto', width: 'auto'})); + } else { + $font.remove(); + } + }); +} + +/** + * Converts images which were the result of a font icon convertion to a font + * icon again. + * + * @param {jQuery} $editable - the element in which the images will be converted + * back to font icons + */ +function imgToFont($editable) { + $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () { + var $img = $(this); + $img.replaceWith($('', { + class: $img.data('class'), + style: $img.data('style') + })); + }); +} + +/* + * Utility function to apply function over descendants elements + * + * This is needed until the following issue of jQuery is solved: + * https://github.com./jquery/sizzle/issues/403 + * + * @param {Element} node The root Element node + * @param {Function} func The function applied over descendants + */ +function applyOverDescendants(node, func) { + node = node.firstChild; + while (node) { + if (node.nodeType === 1) { + func(node); + applyOverDescendants(node, func); + } + var $node = $(node); + if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) { + node = $(node).parents('.o_outlook_hack')[0]; + } + else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) { + node = $node.parent()[0]; + } + node = node.nextSibling; + } +} + +/** + * Converts css style to inline style (leave the classes on elements but forces + * the style they give as inline style). + * + * @param {jQuery} $editable + */ +function classToStyle($editable) { + if (!rulesCache.length) { + getMatchedCSSRules($editable[0]); + } + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = $target.attr('style') || ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + if (_.isEmpty(style)) { + $target.removeAttr('style'); + } else { + $target.attr('style', style); + } + // Apple Mail + if (node.nodeName === 'TD' && !node.childNodes.length) { + node.innerHTML = ' '; + } + + // Outlook + if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) { + var $hack = $('
'); + $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('

'); + } + }); +} + +/** + * Removes the inline style which is not necessary (because, for example, a + * class on an element will induce the same style). + * + * @param {jQuery} $editable + */ +function styleToClass($editable) { + // Outlook revert + $editable.find('.o_outlook_hack').each(function () { + $(this).after($('a,img', this)); + }).remove(); + + getMatchedCSSRules($editable[0]); + + var $c = $('').appendTo(document.body); + + applyOverDescendants($editable[0], function (node) { + var $target = $(node); + var css = getMatchedCSSRules(node); + var style = ''; + _.each(css, function (v,k) { + if (!(new RegExp('(^|;)\s*' + k).test(style))) { + style = k+':'+v+';'+style; + } + }); + css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); + style = $target.attr('style') || ''; + _.each(css, function (v) { + style = style.replace(v, ''); + }); + style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, ''); + if (style !== '') { + $target.attr('style', style); + } else { + $target.removeAttr('style'); + } + }); + $c.remove(); +} + +/** + * Converts css display for attachment link to real image. + * Without this post process, the display depends on the css and the picture + * does not appear when we use the html without css (to send by email for e.g.) + * + * @param {jQuery} $editable + */ +function attachmentThumbnailToLinkImg($editable) { + $editable.find('a[href*="/web/content/"][data-mimetype]:empty').each(function () { + var $link = $(this); + var $img = $('') + .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, '')) + .css('height', Math.max(1, $link.height()) + 'px') + .css('width', Math.max(1, $link.width()) + 'px'); + $link.append($img); + }); +} + +/** + * Revert attachmentThumbnailToLinkImg changes + * + * @see attachmentThumbnailToLinkImg + * @param {jQuery} $editable + */ +function linkImgToAttachmentThumbnail($editable) { + $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove(); +} + +return { + fontToImg: fontToImg, + imgToFont: imgToFont, + classToStyle: classToStyle, + styleToClass: styleToClass, + attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg, + linkImgToAttachmentThumbnail: linkImgToAttachmentThumbnail, +}; +}); -- cgit v1.2.3 From f296992317e96562c66bd7ad59a5080d6c551ed5 Mon Sep 17 00:00:00 2001 From: Christophe Matthieu Date: Thu, 10 Jan 2019 11:07:17 +0000 Subject: [IMP] web_editor,*: Refactoring the wysiwyg editor and 'html' field * Creating a new structure by transforming all the plugins in the library using the odoo inheritance system. Plugins are easier to implement with the AbstractPlugin to add Odoo behaviors. * From now on, the methods of the library (in this case Summernote) can no longer be called by other modules or files. Only the wysiwyg widgets can access it, to simplify the updating process. The wysiwyg object serves as an interface. * Depending on the options the snippets will be loaded or not, the editor will be in an iframe or not... all of this is transparent from the outside. * Regarding iframes, all controllers related to editing have been removed: the new API no longer needs them. This speeds up loading, eases testing and removes complexity for the same features. PUBLIC FEATURES There are several public methods on the Wysiwyg class: * Wysiwyg.prepare (WidgetParent): returns a deferred resolved when the library (xml, lazy, assets...) is loaded. * Wysiwyg.getRange (DOM): returns the range (selection in the dom) * Wysiwyg.setRange (startNode, startOffset, endNode, endOffset): creates a range (selection in the dom) * Wysiwyg.setRangeFromNode (DOM, options) that creates a range from an element (option available to select all, start or end) A jQuery selector was added: :o_editable, which indicates whether the current element is editable. That is, if it is contained in a tag with the attribute 'contentEditable = "true"' or in a tag with the class o_editable. Several methods are also present: * focusIn: makes a focus and places the cursor at the beginning of the element * focusInEnd: makes a focus and places the cursor at the end of the element * selectContent: makes a focus and selects the content HTML FIELD The HTML field can receive different options: * style-inline: {boolean} transforms a class into an inline style when saving and vice versa when reading. * no-attachment: {boolean} prevents the use of attachments (in media dialog) * cssEdit: {xml_id} to use a template containing the css to loaded in an iframe when editing * cssReadonly: {xml_id} to use a template containing the css to load into an iframe when viewing in readonly * snippets: {xml_id} snippets template (can be used with or without cssEdit) * wrapper: {template} qweb static template (containing a tag: id = "wrapper") that will include the content during editing (removed on save) MASS MAILING A widget was created for mass mailing. There are now two fields: body_html and body_arch. body_arch contains the code with the class without conversion into inline style, useful when editing and one with the inline style that is visible in readonly mode and sent by email. Advantage: no spreading errors, able to update css/theme, able to do more changes when converting to inline style so that a maximum of mail clients have an impeccable rendering. Co-authored-by: Antoine Guenet --- .../static/src/js/backend/convert_inline.js | 109 ++++++++++++++++++--- 1 file changed, 93 insertions(+), 16 deletions(-) (limited to 'addons/web_editor/static/src/js/backend/convert_inline.js') diff --git a/addons/web_editor/static/src/js/backend/convert_inline.js b/addons/web_editor/static/src/js/backend/convert_inline.js index 85903f6b4eb..3b09bef5f31 100644 --- a/addons/web_editor/static/src/js/backend/convert_inline.js +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -1,9 +1,8 @@ -odoo.define('web_editor.transcoder', function (require) { +odoo.define('web_editor.convertInline', function (require) { 'use strict'; -var base = require('web_editor.base'); - -var rulesCache = []; +var fonts = require('wysiwyg.fonts'); +var FieldHtml = require('web_editor.field.html'); /** * Returns the css rules which applies on an element, tweaked so that they are @@ -14,8 +13,11 @@ var rulesCache = []; */ function getMatchedCSSRules(a) { var i, r, k; + var doc = a.ownerDocument; + var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); + if (!rulesCache.length) { - var sheets = document.styleSheets; + 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 @@ -190,8 +192,8 @@ function fontToImg($editable) { $editable.find('.fa').each(function () { var $font = $(this); var icon, content; - _.find(base.fontIcons, function (font) { - return _.find(base.getCssSelectors(font.parser), function (data) { + _.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]; @@ -265,9 +267,6 @@ function applyOverDescendants(node, func) { * @param {jQuery} $editable */ function classToStyle($editable) { - if (!rulesCache.length) { - getMatchedCSSRules($editable[0]); - } applyOverDescendants($editable[0], function (node) { var $target = $(node); var css = getMatchedCSSRules(node); @@ -328,9 +327,7 @@ function styleToClass($editable) { $(this).after($('a,img', this)); }).remove(); - getMatchedCSSRules($editable[0]); - - var $c = $('').appendTo(document.body); + var $c = $('').appendTo($editable[0].ownerDocument.body); applyOverDescendants($editable[0], function (node) { var $target = $(node); @@ -342,7 +339,7 @@ function styleToClass($editable) { } }); css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/); - style = $target.attr('style') || ''; + style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';'); _.each(css, function (v) { style = style.replace(v, ''); }); @@ -364,13 +361,13 @@ function styleToClass($editable) { * @param {jQuery} $editable */ function attachmentThumbnailToLinkImg($editable) { - $editable.find('a[href*="/web/content/"][data-mimetype]:empty').each(function () { + $editable.find('a[href*="/web/content/"][data-mimetype]').filter(':empty, :containsExact( )').each(function () { var $link = $(this); var $img = $('') .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, '')) .css('height', Math.max(1, $link.height()) + 'px') .css('width', Math.max(1, $link.width()) + 'px'); - $link.append($img); + $link.prepend($img); }); } @@ -384,6 +381,86 @@ function linkImgToAttachmentThumbnail($editable) { $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove(); } + +//-------------------------------------------------------------------------- +//-------------------------------------------------------------------------- + + +FieldHtml.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + commitChanges: function () { + if (!this.wysiwyg) { + return this._super(); + } + if (this.nodeOptions['style-inline']) { + 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(); + $editable.html(html); + + attachmentThumbnailToLinkImg($editable); + fontToImg($editable); + classToStyle($editable); + 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); + this.wysiwyg.setValue($editable.html(), { + notifyChange: false, + }); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onLoadWysiwyg: function () { + if (this.nodeOptions['style-inline']) { + this._fromInline(); + } + this._super(); + }, +}); + return { fontToImg: fontToImg, imgToFont: imgToFont, -- cgit v1.2.3