From 066e1c7c84a689354bc5a844e72dfd9fddfe1ad5 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Fri, 24 Sep 2021 19:27:55 +0000 Subject: [PATCH 01/20] [IMP] mass_mailing: multiple transpiler improvements This brings a series of improvements to the mechanism in place to convert the output html of mass mailing into html that is more compliant with the main mail clients' requirements. The biggest change is the automatic conversion of Bootstrap grids into table structures. Currently all templates in `mass_mailing` are designed with tables so they work in mailings as is. That has limitations though as it makes it less easy to edit with snippets such as those used in the website builder. This new automatic conversion from Bootstrap grid will allow us to adapt the mailing and snippet templates and be more free within `mass_mailing`. Note: Because of the limited support of media queries in emails, this doesn't support the mixing and matching of column options (e.g., `"col-4 col-sm-6"` and `"col col-4"` aren't supported). Other changes include: - The conversion of Bootstrap cards to table structures - The conversion of Bootstrap list-groups to table structures - The conversion of snippets (.o_mail_snippet_general) and mailings (.o_layout) into table structures - The conversion of all rgb colors to hexadecimal - The conversion of all "rem" sizes to "px" - Various small corrections to the output styles task-2554899 X-original-commit: e2e00939e09220051f6f4ecfb7bb3276105387bf Part-of: odoo/odoo#77724 --- .../static/src/js/mass_mailing_widget.js | 26 +- addons/mass_mailing/tests/test_mailing_ui.py | 2 +- addons/web_editor/controllers/main.py | 15 +- .../static/src/js/backend/convert_inline.js | 518 ++++++++++++++++-- 4 files changed, 505 insertions(+), 56 deletions(-) diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js index c17abbf7d6c..b5e37b3b3fd 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js @@ -67,18 +67,26 @@ var MassMailingFieldHtml = FieldHtml.extend({ self._isDirty = self.wysiwyg.isDirty(); self._doAction(); + // fix outlook image rendering bug (this change will be kept in both + // fields) + _.each(['width', 'height'], function (attribute) { + $editable.find('img').attr(attribute, function () { + return $(this)[attribute](); + }).css(attribute, function () { + return $(this).get(0).style[attribute] || attribute === 'width' ? $(this)[attribute]() + 'px' : 'auto'; + }); + }); + convertInline.attachmentThumbnailToLinkImg($editable); convertInline.fontToImg($editable); convertInline.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'; - }); - }); + convertInline.bootstrapToTable($editable); + convertInline.cardToTable($editable); + convertInline.listGroupToTable($editable); + convertInline.addTables($editable); + convertInline.formatTables($editable); + convertInline.normalizeColors($editable); + convertInline.normalizeRem($editable); self.trigger_up('field_changed', { dataPointID: self.dataPointID, diff --git a/addons/mass_mailing/tests/test_mailing_ui.py b/addons/mass_mailing/tests/test_mailing_ui.py index 808bb608d1c..22b6ab5e224 100644 --- a/addons/mass_mailing/tests/test_mailing_ui.py +++ b/addons/mass_mailing/tests/test_mailing_ui.py @@ -20,4 +20,4 @@ class TestUi(HttpCaseWithUserDemo): # for email client compatibility should be saved in body_html. This # ensures both fields have different values. self.assertEqual(mail.body_arch, '


') - self.assertEqual(mail.body_html, '


') + self.assertEqual(mail.body_html, '


') diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index a02e3e5d2c7..7349542fa5b 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -35,13 +35,17 @@ class Web_Editor(http.Controller): '/web_editor/font_to_img//', '/web_editor/font_to_img///', '/web_editor/font_to_img////', + '/web_editor/font_to_img///', + '/web_editor/font_to_img////', + '/web_editor/font_to_img/////', ], type='http', auth="none") - def export_icon_to_png(self, icon, color='#000', size=100, alpha=255, font='/web/static/lib/fontawesome/fonts/fontawesome-webfont.ttf'): + def export_icon_to_png(self, icon, color='#000', bg=None, size=100, alpha=255, font='/web/static/lib/fontawesome/fonts/fontawesome-webfont.ttf'): """ This method converts an unicode character to an image (using Font Awesome font by default) and is used only for mass mailing because custom fonts are not supported in mail. :param icon : decimal encoding of unicode character :param color : RGB code of the color + :param bg : RGB code of the background color :param size : Pixels in integer :param alpha : transparency of the image from 0 to 255 :param font : font path @@ -57,6 +61,11 @@ class Web_Editor(http.Controller): # if received character is not a number, keep old behaviour (icon is character) icon = chr(int(icon)) if icon.isdigit() else icon + # Background standardization + if bg is not None and bg.startswith('rgba'): + bg = bg.replace('rgba', 'rgb') + bg = ','.join(bg.split(',')[:-1])+')' + # Determine the dimensions of the icon image = Image.new("RGBA", (size, size), color=(0, 0, 0, 0)) draw = ImageDraw.Draw(image) @@ -78,8 +87,8 @@ class Web_Editor(http.Controller): iconimage.putalpha(imagemask) # Create output image - outimage = Image.new("RGBA", (boxw, size), (0, 0, 0, 0)) - outimage.paste(iconimage, (left, top)) + outimage = Image.new("RGBA", (boxw, size), bg or (0, 0, 0, 0)) + outimage.paste(iconimage, (left, top), iconimage) # output image output = io.BytesIO() 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 ff285255286..7ad783af1b5 100644 --- a/addons/web_editor/static/src/js/backend/convert_inline.js +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -4,6 +4,21 @@ odoo.define('web_editor.convertInline', function (require) { var FieldHtml = require('web_editor.field.html'); const SELECTORS_IGNORE = /(^\*$|:hover|:before|:after|:active|:link|::|'|\([^(),]+[,(])/; + +// Note: duplicated from odoo-editor utils. +// DOES NOT SUPPORT RGBA (Outlook doesn't support transparency) +function rgbToHex(rgb = '') { + return ( + '#' + + (rgb.match(/\d{1,3}/g) || []) + .map(x => { + x = parseInt(x).toString(16); + return x.length === 1 ? '0' + x : x; + }) + .join('') + ); +} + /** * Returns the css rules which applies on an element, tweaked so that they are * browser/mail client ok. @@ -12,7 +27,7 @@ const SELECTORS_IGNORE = /(^\*$|:hover|:before|:after|:active|:link|::|'|\([^(), * @returns {Object} css property name -> css property value */ function getMatchedCSSRules(a) { - var i, r, k; + var i, r, k, l; var doc = a.ownerDocument; var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); @@ -29,11 +44,37 @@ function getMatchedCSSRules(a) { } if (rules) { for (r = rules.length-1; r >= 0; r--) { + const conditionText = rules[r].conditionText; + const minWidthMatch = conditionText && conditionText.match(/\(min-width *: *(\d+)/); + const minWidth = minWidthMatch && +(minWidthMatch[1] || '0'); + if (minWidth && minWidth >= 1200) { + // Large min-width media queries should be included. + // eg., .container has a default max-width for all + // screens. + let mediaRules; + try { + mediaRules = rules[r].rules || rules[r].cssRules; + } catch (e) { + console.log(`Can't read the css rules of: ${sheets[i].href} (${conditionText})`, e); + continue; + } + if (mediaRules) { + for (k = mediaRules.length-1; k >= 0; k--) { + var selectorText = mediaRules[k].selectorText; + if (selectorText && !SELECTORS_IGNORE.test(selectorText)) { + var st = selectorText.split(/\s*,\s*/); + for (l = 0 ; l < st.length ; l++) { + rulesCache.push({ 'selector': st[l], 'style': mediaRules[k].style }); + } + } + } + } + } var selectorText = rules[r].selectorText; if (selectorText && !SELECTORS_IGNORE.test(selectorText)) { var st = selectorText.split(/\s*,\s*/); - for (k = 0 ; k < st.length ; k++) { - rulesCache.push({ 'selector': st[k], 'style': rules[r].style }); + for (l = 0 ; l < st.length ; l++) { + rulesCache.push({ 'selector': st[l], 'style': rules[r].style }); } } } @@ -93,9 +134,12 @@ function getMatchedCSSRules(a) { } }); - if (style.display === 'block') { + if (style.display === 'block' && !(a.classList && a.classList.contains('btn-block'))) { delete style.display; } + if (!style['box-sizing']) { + style['box-sizing'] = 'border-box'; // This is by default with Bootstrap. + } // 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. @@ -157,18 +201,34 @@ function getMatchedCSSRules(a) { delete style['text-decoration-thickness']; } - // 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; + // color and text-align inheritance do not seem to get past elements on + // some mail clients. TODO: This is hacky as it applies a color/text-align + // style to all descendants of nodes with a color style. We can probably do + // this more elegantly. + if (style.color || style['text-align']) { + function _styleDescendants(node, styleName) { + const camelCased = styleName.replace(/-(\w)/g, match => match[1].toUpperCase()); + node.style[camelCased] = style[styleName]; + for (const child of $(node).children()) { + const childStyle = $(child).css(styleName); + if (childStyle && childStyle !== 'inherit') { + _styleDescendants(child, styleName); + } } - $el = $el.parent(); - } while ($el.length && !$el.is('html')); + } + if (style.color) { + _styleDescendants(a, 'color'); + } + if (style['text-align']) { + _styleDescendants(a, 'text-align'); + } + } + + // flexboxes are not supported in Windows Outlook + for (const styleName in style) { + if (styleName.includes('flex') || `${style[styleName]}`.includes('flex')) { + delete style[styleName]; + } } return style; @@ -197,13 +257,45 @@ function fontToImg($editable) { }); 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, Math.round($font.height()))), + let $backgroundColoredElement = $font; + let bg, isTransparent; + do { + bg = $backgroundColoredElement.css('background-color').replace(/\s/g, ''); + isTransparent = bg === 'transparent' || bg === 'rgba(0,0,0,0)'; + $backgroundColoredElement = $backgroundColoredElement.parent(); + } while (isTransparent && $backgroundColoredElement[0]); + if (bg === 'rgba(0,0,0,0)' && isTransparent) { + // default on white rather than black background since opacity + // is not supported. + bg = 'rgb(255,255,255)'; + } + const style = $font.attr('style'); + const width = $font.width(); + const height = $font.height(); + const lineHeight = $font.css('line-height'); + // Compute the padding. + // First get the dimensions of the icon itself (::before) + $font.css({height: 'fit-content', width: 'fit-content', 'line-height': 'normal'}); + const hPadding = width && (width - $font.width()) / 2; + const vPadding = height && (height - $font.height()) / 2; + let padding = ''; + if (hPadding || vPadding) { + padding = vPadding ? vPadding + 'px ' : '0 '; + padding += hPadding ? hPadding + 'px' : '0'; + } + const $img = $('').attr({ + width, height, + src: `/web_editor/font_to_img/${content.charCodeAt(0)}/${window.encodeURI(color)}/${window.encodeURI(bg)}/${Math.max(1, $font.height())}`, 'data-class': $font.attr('class'), - 'data-style': $font.attr('style'), + 'data-style': 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'})); + style, + }).css({ + 'box-sizing': 'border-box', // keep the fontawesome's dimensions + 'line-height': lineHeight, + padding, width: width + 'px', height: height + 'px', + }); + $font.replaceWith($img); } else { $font.remove(); } @@ -237,6 +329,312 @@ function applyOverDescendants(node, func) { } } +const reColMatch = /(^| )col(-[\w\d]+)*( |$)/; +const reOffsetMatch = /(^| )offset(-[\w\d]+)*( |$)/; +function _getColumnSize(column) { + const colMatch = column.className.match(reColMatch); + const colOptions = colMatch[2] && colMatch[2].substr(1).split('-'); + const colSize = colOptions && (colOptions.length === 2 ? +colOptions[1] : +colOptions[0]) || 0; + const offsetMatch = column.className.match(reOffsetMatch); + const offsetOptions = offsetMatch && offsetMatch[2] && offsetMatch[2].substr(1).split('-'); + const offsetSize = offsetOptions && (offsetOptions.length === 2 ? +offsetOptions[1] : +offsetOptions[0]) || 0; + return colSize + offsetSize; +} + + +// Attributes all tables should have in a mailing. +const tableAttributes = { + cellspacing: 0, + cellpadding: 0, + border: 0, + width: '100%', + align: 'center', + role: 'presentation', +}; +// Cancel tables default styles. +const tableStyles = { + 'border-collapse': 'collapse', + 'text-align': 'inherit', +}; +function _createTable(attributes = []) { + const $table = $(''); + $table.attr(tableAttributes).css(tableStyles); + $table[0].style.setProperty('width', '100%', 'important'); + for (const attr of attributes) { + if (attr.name !== 'width' && attr.value !== '100%') { + $table.attr(attr.name, attr.value); + } + } + return $table; +} +function _createColumnGrid() { + return new Array(12).fill().map(() => $(''); + for (const attr of bootstrapRow.attributes) { + $row.attr(attr.name, attr.value); + } + $row.removeClass('row'); + for (const child of [...bootstrapRow.childNodes]) { + $row.append(child); + } + $bootstrapRow.before($row); + $bootstrapRow.remove(); + + + // COLUMNS + const $bootstrapColumns = $row.children().filter((i, column) => column.className && column.className.match(reColMatch)); + + // 1. Replace generic "col" classes with specific "col-n", computed + // by sharing the available space between them. + const $flexColumns = $bootstrapColumns.filter((i, column) => !/\d/.test(column.className.match(reColMatch)[0] || '0')); + const colTotalSize = $bootstrapColumns.toArray().map(child => _getColumnSize(child)).reduce((a, b) => a + b); + const colSize = Math.round((12 - colTotalSize) / $flexColumns.length); + for (const flexColumn of $flexColumns) { + flexColumn.classList.remove(flexColumn.className.match(reColMatch)[0].trim()); + flexColumn.classList.add(`col-${colSize}`); + } + + // 2. Create and fill up the row(s) with grid(s). + let grid = _createColumnGrid(); + let gridIndex = 0; + let $currentRow = $($row[0].cloneNode()); + $row.after($currentRow); + let $currentCol; + let columnIndex = 0; + for (const bootstrapColumn of $bootstrapColumns) { + const columnSize = _getColumnSize(bootstrapColumn); + if (gridIndex + columnSize < 12) { + $currentCol = grid[gridIndex]; + _applyColspanToGridElement($currentCol, columnSize); + gridIndex += columnSize; + if (columnIndex === $bootstrapColumns.length - 1) { + // We handled all the columns but there is still space + // in the row. Insert the columns and fill the row. + grid[gridIndex].attr('colspan', 12 - gridIndex); + $currentRow.append(...grid.filter(td => td.attr('colspan'))); + } + } else if (gridIndex + columnSize === 12) { + // Finish the row. + $currentCol = grid[gridIndex]; + _applyColspanToGridElement($currentCol, columnSize); + $currentRow.append(...grid.filter(td => td.attr('colspan'))); + if (columnIndex !== $bootstrapColumns.length - 1) { + // The row was filled before we handled all of its + // columns. Create a new one and start again from there. + const $previousRow = $currentRow; + $currentRow = $($currentRow[0].cloneNode()); + $previousRow.after($currentRow); + grid = _createColumnGrid(); + gridIndex = 0; + } + } else { + // Fill the row with what was in the grid before it + // overflowed. + _applyColspanToGridElement(grid[gridIndex], 12 - gridIndex); + $currentRow.append(...grid.filter(td => td.attr('colspan'))); + // Start a new row that starts with the current col. + const $previousRow = $currentRow; + $currentRow = $($currentRow[0].cloneNode()); + $previousRow.after($currentRow); + grid = _createColumnGrid(); + $currentCol = grid[0]; + _applyColspanToGridElement($currentCol, columnSize); + gridIndex = columnSize; + } + if ($currentCol) { + for (const attr of bootstrapColumn.attributes) { + if (attr.name !== 'colspan') { + $currentCol.attr(attr.name, attr.value); + } + } + const colMatch = bootstrapColumn.className.match(reColMatch); + $currentCol.removeClass(colMatch[0]); + for (const child of [...bootstrapColumn.childNodes]) { + $currentCol.append(child); + } + // Adapt width to colspan. + _applyColspanToGridElement($currentCol, +$currentCol.attr('colspan')); + } + columnIndex++; + } + $row.remove(); // $row was cloned and inserted already + } + } +} + +function cardToTable($editable) { + for (const card of $editable.find('.card')) { + const $card = $(card); + // Table + const $table = _createTable(card.attributes); + for (const child of [...card.childNodes]) { + if (child.nodeType === Node.TEXT_NODE) { + $table.append(child); + } else { + const $row = $(''); + const $col = $(''); + const $col = $(''); + const $col = $(''); + const $colB = $('
')); +} +function _applyColspanToGridElement($gridElement, colspan) { + $gridElement.attr('colspan', colspan); + const width = Math.round(+$gridElement.attr('colspan') * 100 / 12) + '%'; + $gridElement.attr('width', width); + $gridElement.css('width', width); +} + +/** + * Converts bootstrap rows and columns to actual tables. + * + * Note: Because of the limited support of media queries in emails, this doesn't + * support the mixing and matching of column options (e.g., "col-4 col-sm-6" and + * "col col-4" aren't supported). + * + * @param {jQuery} $editable + */ +function bootstrapToTable($editable) { + // First give all rows in columns a separate container parent. + $editable.find('.row').filter((i, row) => reColMatch.test(row.parentElement.className)).wrap('
'); + + // Now convert all containers with rows to tables. + for (const container of $editable.find('.container:has(.row), .container-fluid:has(.row), .o_fake_table:has(.row)')) { + const $container = $(container); + + + // TABLE + const $table = _createTable(container.attributes); + for (const child of [...container.childNodes]) { + $table.append(child); + } + $table.removeClass('container container-fluid o_fake_table'); + $container.before($table); + $container.remove(); + + + // ROWS + const $bootstrapRows = $table.children().filter('.row'); + for (const bootstrapRow of $bootstrapRows) { + const $bootstrapRow = $(bootstrapRow); + const $row = $('
'); + if (child.nodeName === 'IMG') { + $col.append(child); + } else { + for (const attr of child.attributes) { + $col.attr(attr.name, attr.value); + } + for (const descendant of [...child.childNodes]) { + $col.append(descendant); + } + $(child).remove(); + } + $row.append($col); + $table.append($row); + } + } + $card.before($table); + $card.remove(); + } +} + +function listGroupToTable($editable) { + for (const listGroup of $editable.find('.list-group')) { + const $listGroup = $(listGroup); + // Table + let $table; + if ($listGroup.find('.list-group-item').length) { + $table = _createTable(listGroup.attributes); + } else { + $table = $(listGroup.cloneNode()); + for (const attr of $listGroup.attributes) { + $table.attr(attr.name, attr.value); + } + } + for (const child of [...listGroup.childNodes]) { + const $child = $(child); + if ($child.hasClass('list-group-item')) { + // List groups are
    s that render like tables. Their + // li.list-group-item children should translate to tr > td. + const $row = $('
'); + for (const attr of child.attributes) { + $col.attr(attr.name, attr.value); + } + for (const descendant of [...child.childNodes]) { + $col.append(descendant); + } + $col.removeClass('list-group-item'); + $row.append($col); + $table.append($row); + $(child).remove(); + } else { + $table.append(child); + } + } + $table.removeClass('list-group'); + if ($listGroup.is('td')) { + $listGroup.append($table); + $listGroup.removeClass('list-group'); + } else { + $listGroup.before($table); + $listGroup.remove(); + } + } +} + +function addTables($editable) { + for (const snippet of $editable.find('.o_mail_snippet_general, .o_layout')) { + // Convert all snippets and the mailing itself into table > tr > td + const $table = _createTable(snippet.attributes); + const $row = $('
'); + $row.append($col); + $table.append($row); + for (const child of [...snippet.childNodes]) { + $col.append(child); + } + $(snippet).before($table); + $(snippet).remove(); + + // If snippet doesn't have a table as child, wrap its contents in one. + if (!$col.children().filter('table')) { + const $tableB = _createTable(); + $tableB[0].style.width + const $rowB = $('
'); + $rowB.append($colB); + $tableB.append($rowB); + for (const child of [...$table[0].childNodes]) { + $colB.append(child); + } + $col.append($tableB); + } + } +} + +const rePadding = /(\d+)/; +function formatTables($editable) { + for (const table of $editable.find('table.o_mail_snippet_general, .o_mail_snippet_general table')) { + const $table = $(table); + const tablePaddingTop = +$table.css('padding-top').match(rePadding)[1]; + const tablePaddingRight = +$table.css('padding-right').match(rePadding)[1]; + const tablePaddingBottom = +$table.css('padding-bottom').match(rePadding)[1]; + const tablePaddingLeft = +$table.css('padding-left').match(rePadding)[1]; + for (const column of $table.find('td').filter((i, td) => $(td).closest('table').is($table))) { + const $column = $(column); + if ($column.css('padding')) { + const columnPaddingTop = +$column.css('padding-top').match(rePadding)[1]; + const columnPaddingRight = +$column.css('padding-right').match(rePadding)[1]; + const columnPaddingBottom = +$column.css('padding-bottom').match(rePadding)[1]; + const columnPaddingLeft = +$column.css('padding-left').match(rePadding)[1]; + $column.css({ + 'padding-top': columnPaddingTop + tablePaddingTop, + 'padding-right': columnPaddingRight + tablePaddingRight, + 'padding-bottom': columnPaddingBottom + tablePaddingBottom, + 'padding-left': columnPaddingLeft + tablePaddingLeft, + }); + } + } + $table.css('padding', ''); + } +} + /** * Converts css style to inline style (leave the classes on elements but forces * the style they give as inline style). @@ -247,9 +645,17 @@ function classToStyle($editable) { applyOverDescendants($editable[0], function (node) { var $target = $(node); var css = getMatchedCSSRules(node); + // Flexbox + for (const styleName in node.style) { + if (styleName.includes('flex') || `${node.style[styleName]}`.includes('flex')) { + node.style[styleName] = ''; + } + } + + // Do not apply css that would override inline styles (which are prioritary). var style = $target.attr('style') || ''; _.each(css, function (v,k) { - if (!(new RegExp('(^|;)\s*' + k).test(style))) { + if (!(new RegExp('(^|;)\\s*' + k).test(style))) { style = k+':'+v+';'+style; } }); @@ -258,6 +664,9 @@ function classToStyle($editable) { } else { $target.attr('style', style); } + if ($target.get(0).style.width) { + $target.attr('width', $target.css('width')); // Widths need to be applied as attributes as well. + } // Apple Mail if (node.nodeName === 'TD' && !node.childNodes.length) { $(node).html(' '); @@ -265,26 +674,8 @@ function classToStyle($editable) { // 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(); - } + $target.prepend(``); + $target.append(``); } else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { $target.wrap('

'); @@ -292,6 +683,33 @@ function classToStyle($editable) { }); } +// Note: ignores rgba colors, which are not supported in Outlook. +function normalizeColors($editable) { + for (const node of $editable.find('[style*="rgb"]')) { + const rgbMatch = node.getAttribute('style').match(/rgb?\(([\d\.]*,?\s?){3,4}\)/g); + for (const rgb of rgbMatch || []) { + node.setAttribute('style', node.getAttribute('style').replace(rgb, rgbToHex(rgb))); + } + } +} + +/** + * Converts all css values that use the rem unit to px. + * + * @param {JQuery} $editable + */ +function normalizeRem($editable) { + const rootFontSizeProperty = $editable.closest('html').css('font-size'); + const rootFontSize = parseFloat(rootFontSizeProperty.replace(/[^\d\.]/g, '')); + for (const node of $editable.find('[style*="rem"]')) { + const remMatch = node.getAttribute('style').match(/[\d\.]+\s*rem/g); + for (const rem of remMatch || []) { + const remValue = parseFloat(rem.replace(/[^\d\.]/g, '')); + node.setAttribute('style', node.getAttribute('style').replace(rem, remValue * rootFontSize + 'px')); + } + } +} + /** * Converts css display for attachment link to real image. * Without this post process, the display depends on the css and the picture @@ -353,13 +771,20 @@ FieldHtml.include({ attachmentThumbnailToLinkImg($editable); fontToImg($editable); classToStyle($editable); + bootstrapToTable($editable); + cardToTable($editable); + listGroupToTable($editable); + addTables($editable); + formatTables($editable); + normalizeColors($editable); + normalizeRem($editable); // fix outlook image rendering bug _.each(['width', 'height'], function(attribute) { - $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function(){ + $editable.find('img').attr(attribute, function(){ return $(this)[attribute](); }).css(attribute, function(){ - return $(this).get(0).style[attribute] || 'auto'; + return $(this).get(0).style[attribute] || $(this)[attribute]() || 'auto'; }); }); $odooEditor.addClass('odoo-editor'); @@ -372,7 +797,14 @@ FieldHtml.include({ return { fontToImg: fontToImg, + bootstrapToTable: bootstrapToTable, + cardToTable: cardToTable, + listGroupToTable: listGroupToTable, + addTables: addTables, + formatTables: formatTables, classToStyle: classToStyle, + normalizeColors: normalizeColors, + normalizeRem: normalizeRem, attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg, }; }); From 5de7bdb924a53af11891d6aa0277c4b1a19aaeb9 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Fri, 24 Sep 2021 19:52:40 +0000 Subject: [PATCH 02/20] [IMP] (website_)mass_mailing: convert tables to bootstrap grids The new automatic conversion from Bootstrap grid to tables allows us to convert `mass_mailing` templates from their old table structures to Bootstrap grid (as the reverse conversion will now be applied on save). General heuristic for the conversion of templates: | **Table** | **Bootstrap** | |:-------------------:|:------------------------------------------:| | `` | `
` | | `
` | _(removed)_ | | `` | _(removed)_ | | `` | _(removed)_ | | `` | `
` | | `
` | `
` | | `
` | `
` | | `
` | `
` where o ~= 12n/100 | | `cellspacing` | _(removed but always added back to table)_ | | `cellpadding` | _(removed but always added back to table)_ | | `border` | _(removed but always added back to table)_ | Some styles had to be changed to account for the difference in the HTML. task-2554899 X-original-commit: c26dac24c5fd15165935c770175c1b6f87aaad41 Part-of: odoo/odoo#77724 --- .../mass_mailing/data/mass_mailing_demo.xml | 137 +++---- .../static/src/js/mass_mailing_widget.js | 22 +- .../static/src/scss/themes/theme_default.scss | 21 +- addons/mass_mailing/views/snippets_themes.xml | 366 +++++++++--------- .../mass_mailing/views/themes_templates.xml | 16 +- .../views/snippets_templates.xml | 2 +- 6 files changed, 263 insertions(+), 301 deletions(-) diff --git a/addons/mass_mailing/data/mass_mailing_demo.xml b/addons/mass_mailing/data/mass_mailing_demo.xml index 67157c1b7dd..18b2492d617 100644 --- a/addons/mass_mailing/data/mass_mailing_demo.xml +++ b/addons/mass_mailing/data/mass_mailing_demo.xml @@ -87,90 +87,77 @@ Info <info@yourcompany.example.com>
- - - - - - - -
-
- - - - - - -
- Powered by Odoo -
+
+
+ + +
+
+
+ Powered by Odoo +
+
+
diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js index b5e37b3b3fd..4240a8c06a7 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js @@ -260,26 +260,16 @@ var MassMailingFieldHtml = FieldHtml.extend({ } else { // This wrapper structure is the only way to have a responsive // and centered fixed-width content column on all mail clients - $new_wrapper = $('', { - class: 'o_mail_wrapper' + $new_wrapper = $('
', { + class: 'container o_mail_wrapper o_mail_regular', }); - $newWrapperContent = $('
').append( - $('
', { - class: 'o_mail_no_options o_mail_wrapper_td oe_structure' + $newWrapperContent = $('
', { + class: 'col o_mail_no_options o_mail_wrapper_td oe_structure' }); - $new_wrapper.append($('
', { - class: 'o_mail_no_resize o_not_editable', - contenteditable: 'false' - }), - $newWrapperContent, - $('', { - class: 'o_mail_no_resize o_not_editable', - contenteditable: 'false' - }) - )); + $new_wrapper.append($('
').append($newWrapperContent)); } var $newLayout = $('
', { - class: 'o_layout ' + themeParams.className + class: 'o_layout ' + themeParams.className, }).append($new_wrapper); var $contents; diff --git a/addons/mass_mailing/static/src/scss/themes/theme_default.scss b/addons/mass_mailing/static/src/scss/themes/theme_default.scss index 2588c025d51..409b0228489 100644 --- a/addons/mass_mailing/static/src/scss/themes/theme_default.scss +++ b/addons/mass_mailing/static/src/scss/themes/theme_default.scss @@ -29,7 +29,7 @@ $o-mm-def-btn-text : $o-mm-def-color-2; @include bg-variant(".bg-o-color-4", $o-mm-def-color-7); @include bg-variant(".bg-gray-lighter", $o-mm-def-color-1); -td:not([align]) { +div.col:not([align]) { // Default browser style but needed so that alignment works on some mail // clients (see convert_inline) text-align: inherit; @@ -58,10 +58,8 @@ td:not([align]) { .o_layout { overflow: hidden; box-sizing: content-box; - width: 94%; min-height: 100%; background-color: $o-mm-def-color-1; - padding: 0 3%; // Forces on

and elements as several mail clients does not // correctly inherit font properties @@ -82,6 +80,7 @@ td:not([align]) { width: $o-mm-def-body-width; border: 1px solid darken($o-mm-def-color-1, 2%); background-color: $o-mm-def-color-2; + flex: none; } } } @@ -89,7 +88,7 @@ td:not([align]) { // ===== Snippets (general) ===== .o_mail_snippet_general { width: 100%; - max-width: $o-mm-def-body-width; // should not be necessary thanks to mail wrapper + box-sizing: border-box; margin: 0 auto; h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { @@ -142,7 +141,7 @@ td:not([align]) { } } - td, th { + div.col { vertical-align: top; } } @@ -161,7 +160,7 @@ td:not([align]) { display: inline-block; } } - td { + div .col, div[class*="col-"] { vertical-align: middle; } } @@ -218,11 +217,11 @@ td:not([align]) { height: 1px; background-color: darken($o-mm-def-color-1, 15%); } - table table { - td, th { + div.container div.container { + div.col:not(.o_col_head) { vertical-align: middle; } - td { + div.col { padding: 10px 20px; * { margin: 0; @@ -285,7 +284,7 @@ td:not([align]) { } } } - td { + div.col:not(.o_col_head) { vertical-align: middle; } } @@ -321,6 +320,6 @@ td:not([align]) { // classes may remain on clients' templates,

are forced to 0 margin to // be sure (normally should not consider margin properties but some mail // clients might make the mistake to do so). -td[class*="mb"], td[class*="mt"] { +div.col[class*="mb"], div.col[class*="mt"] { margin: 0!important; } diff --git a/addons/mass_mailing/views/snippets_themes.xml b/addons/mass_mailing/views/snippets_themes.xml index ce7f3c924a8..ffc4f883325 100644 --- a/addons/mass_mailing/views/snippets_themes.xml +++ b/addons/mass_mailing/views/snippets_themes.xml @@ -84,22 +84,22 @@ @@ -107,9 +107,9 @@ @@ -132,17 +132,17 @@ @@ -168,17 +168,15 @@ @@ -186,29 +184,27 @@ @@ -561,29 +557,27 @@ @@ -591,38 +585,36 @@ @@ -630,36 +622,34 @@ @@ -667,18 +657,18 @@ @@ -758,25 +748,23 @@ @@ -784,26 +772,24 @@ diff --git a/addons/mass_mailing/views/themes_templates.xml b/addons/mass_mailing/views/themes_templates.xml index 6d819395429..6a4e576e586 100644 --- a/addons/mass_mailing/views/themes_templates.xml +++ b/addons/mass_mailing/views/themes_templates.xml @@ -16,17 +16,17 @@ + + + + + + + + + + + + + + + +