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 fac3d6c4d62..9104c2107d3 100644 --- a/addons/web_editor/static/src/js/backend/convert_inline.js +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -4,8 +4,457 @@ import FieldHtml from 'web_editor.field.html'; import { isBlock, rgbToHex } from '../../../lib/odoo-editor/src/utils/utils'; -const SELECTORS_IGNORE = /(^\*$|:hover|:before|:after|:active|:link|::|'|\([^(),]+[,(])/; +//-------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------- +const RE_COL_MATCH = /(^| )col(-[\w\d]+)*( |$)/; +const RE_OFFSET_MATCH = /(^| )offset(-[\w\d]+)*( |$)/; +const RE_PADDING = /([\d.]+)/; +const SELECTORS_IGNORE = /(^\*$|:hover|:before|:after|:active|:link|::|'|\([^(),]+[,(])/; +// Attributes all tables should have in a mailing. +const TABLE_ATTRIBUTES = { + cellspacing: 0, + cellpadding: 0, + border: 0, + width: '100%', + align: 'center', + role: 'presentation', +}; +// Cancel tables default styles. +const TABLE_STYLES = { + 'border-collapse': 'collapse', + 'text-align': 'inherit', + 'font-size': 'unset', + 'line-height': 'unset', +}; + +//-------------------------------------------------------------------------- +// Public +//-------------------------------------------------------------------------- + +/** + * Convert snippets and mailing bodies to tables. + * + * @param {JQuery} $editable + */ +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 = $(''); + const $col = $(''); + $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').length) { + const $tableB = _createTable(); + $tableB[0].style.width + const $rowB = $(''); + const $colB = $(''); + $rowB.append($colB); + $tableB.append($rowB); + for (const child of [...$col[0].childNodes]) { + $colB.append(child); + } + $col.append($tableB); + } + } +} +/** + * Convert 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 = $('') + .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); + }); +} +/** + * Convert 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) => RE_COL_MATCH.test(row.parentElement.className)).wrap('
'); + + // These containers from the mass mailing masonry snippet require full + // height contents, which is only possible if the table itself has a set + // height. We also need to restyle it because of the change in structure. + $editable.find('.o_masonry_grid_container').css('padding', 0) + .find('> .o_fake_table').css('height', function() { return $(this).height() }); + for (const masonryRow of $editable.find('.o_masonry_grid_container > .o_fake_table > .row.h-100')) { + masonryRow.style.removeProperty('height'); + masonryRow.parentElement.style.setProperty('height', '100%'); + } + + // 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'); + if (!$table[0].className) { + $table.removeAttr('class'); + } + $container.before($table); + $container.remove(); + + + // ROWS + // First give all siblings of rows a separate row/col parent combo. + $table.children().filter((i, child) => isBlock(child) && !$(child).hasClass('row')).wrap('
'); + + const $bootstrapRows = $table.children().filter('.row'); + for (const bootstrapRow of $bootstrapRows) { + const $bootstrapRow = $(bootstrapRow); + const $row = $(''); + for (const attr of bootstrapRow.attributes) { + $row.attr(attr.name, attr.value); + } + $row.removeClass('row'); + if (!$row[0].className) { + $row.removeAttr('class'); + } + 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(RE_COL_MATCH)); + + // 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(RE_COL_MATCH)[0] || '0')); + const colTotalSize = $bootstrapColumns.toArray().map(child => _getColumnSize(child)).reduce((a, b) => a + b); + const colSize = Math.max(1, Math.round((12 - colTotalSize) / $flexColumns.length)); + for (const flexColumn of $flexColumns) { + flexColumn.classList.remove(flexColumn.className.match(RE_COL_MATCH)[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]; + _applyColspan($currentCol, 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'))); + } + gridIndex += columnSize; + } else if (gridIndex + columnSize === 12) { + // Finish the row. + $currentCol = grid[gridIndex]; + _applyColspan($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. + _applyColspan(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]; + _applyColspan($currentCol, columnSize); + gridIndex = columnSize; + if (columnIndex === $bootstrapColumns.length - 1 && gridIndex < 12) { + // 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'))); + // Adapt width to colspan. + _applyColspan(grid[gridIndex], 12 - gridIndex); + } + } + if ($currentCol) { + for (const attr of bootstrapColumn.attributes) { + if (attr.name !== 'colspan') { + $currentCol.attr(attr.name, attr.value); + } + } + const colMatch = bootstrapColumn.className.match(RE_COL_MATCH); + $currentCol.removeClass(colMatch[0]); + if (!$currentCol[0].className) { + $currentCol.removeAttr('class'); + } + for (const child of [...bootstrapColumn.childNodes]) { + $currentCol.append(child); + } + // Adapt width to colspan. + _applyColspan($currentCol, +$currentCol.attr('colspan')); + } + columnIndex++; + } + $row.remove(); // $row was cloned and inserted already + } + } +} +/** + * Convert Bootstrap cards to table structures. + * + * @param {JQuery} $editable + */ +function cardToTable($editable) { + for (const card of $editable.find('.card')) { + const $card = $(card); + 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 = $(''); + 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(); + } +} +/** + * Convert CSS style to inline style (leave the classes on elements but forces + * the style they give as inline style). + * + * @param {jQuery} $editable + * @param {Object} cssRules + */ +function classToStyle($editable, cssRules) { + _applyOverDescendants($editable[0], function (node) { + const $target = $(node); + const css = _getMatchedCSSRules(node, cssRules); + // 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). + let style = $target.attr('style') || ''; + for (const [key, value] of Object.entries(css)) { + if (!(new RegExp(`(^|;)\\s*${key}`).test(style))) { + style = `${key}:${value};${style}`; + } + }; + if (_.isEmpty(style)) { + $target.removeAttr('style'); + } 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. + } + + // Media list images should not have an inline height + if (node.nodeName === 'IMG' && $target.hasClass('s_media_list_img')) { + $target.css('height', ''); + } + // Apple Mail + if (node.nodeName === 'TD' && !node.childNodes.length) { + $(node).html(' '); + } + // Outlook + if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) { + $target.prepend(``); + $target.append(``); + } else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { + $target.wrap('

'); + } + }); +} +/** + * Convert 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, ''); + 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': style, + class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style + 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(); + } + }); +} +/** + * Format table styles so they display well in most mail clients. This implies + * moving table paddings to its cells, adding tbody (with canceled styles) where + * needed, and adding pixel heights to parents of elements with percent heights. + * + * @param {JQuery} $editable + */ +function formatTables($editable) { + for (const table of $editable.find('table.o_mail_snippet_general, .o_mail_snippet_general table')) { + const $table = $(table); + const tablePaddingTop = parseFloat($table.css('padding-top').match(RE_PADDING)[1]); + const tablePaddingRight = parseFloat($table.css('padding-right').match(RE_PADDING)[1]); + const tablePaddingBottom = parseFloat($table.css('padding-bottom').match(RE_PADDING)[1]); + const tablePaddingLeft = parseFloat($table.css('padding-left').match(RE_PADDING)[1]); + const $rows = $table.find('tr').filter((i, tr) => $(tr).closest('table').is($table)); + const $columns = $table.find('td').filter((i, td) => $(td).closest('table').is($table)); + for (const column of $columns) { + const $column = $(column); + const $columnsInRow = $column.closest('tr').find('td'); + const columnIndex = $columnsInRow.toArray().findIndex(col => $(col).is($column)); + const rowIndex = $rows.toArray().findIndex(row => $(row).is($column.closest('tr'))); + if (!rowIndex) { + const match = $column.css('padding-top').match(RE_PADDING); + const columnPaddingTop = match ? parseFloat(match[1]) : 0; + $column.css('padding-top', columnPaddingTop + tablePaddingTop); + } + if (columnIndex === $columnsInRow.length - 1) { + const match = $column.css('padding-right').match(RE_PADDING); + const columnPaddingRight = match ? parseFloat(match[1]) : 0; + $column.css('padding-right', columnPaddingRight + tablePaddingRight); + } + if (rowIndex === $rows.length - 1) { + const match = $column.css('padding-bottom').match(RE_PADDING); + const columnPaddingBottom = match ? parseFloat(match[1]) : 0; + $column.css('padding-bottom', columnPaddingBottom + tablePaddingBottom); + } + if (!columnIndex) { + const match = $column.css('padding-left').match(RE_PADDING); + const columnPaddingLeft = match ? parseFloat(match[1]) : 0; + $column.css('padding-left', columnPaddingLeft + tablePaddingLeft); + } + } + $table.css('padding', ''); + } + // Ensure a tbody in every table and cancel its default style. + for (const table of $editable.find('table:not(:has(tbody))')) { + $(table).contents().wrap(''); + } + // Children will only take 100% height if the parent has a height property. + for (const node of $editable.find('*').filter((i, n) => ( + n.style && n.style.getPropertyValue('height') === '100%' && ( + !n.parentElement.style.getPropertyValue('height') || + n.parentElement.style.getPropertyValue('height').includes('%')) + ))) { + let parent = node.parentElement; + let height = parent.style.getPropertyValue('height'); + while (parent && height && height.includes('%')) { + parent = parent.parentElement; + height = parent.style.getPropertyValue('height'); + } + if (parent) { + parent.style.setProperty('height', '0'); + } + } +} /** * Parse through the given document's stylesheets, preprocess(*) them and return * the result as an array of objects, each containing a selector string , a @@ -112,29 +561,138 @@ function getCSSRules(doc) { return groupedRules; } /** - * Take a css style declaration return a "normalized" version of it (as a - * standard object) for the purposes of emails. This means removing its styles - * that are invalid, describe animations or aren't standard css (webkit - * extensions). It also involves adding the "!important" suffix to styles that - * have that priority, so they can be handled without access to the full - * declaration. + * Convert Bootstrap list groups and their items to table structures. * - * @param {CSSStyleDeclaration} style - * @returns {Object} {[styleName]: string} + * @param {JQuery} $editable */ -function _normalizeStyle(style) { - const normalizedStyle = {}; - for (const styleName of style) { - const value = style[styleName]; - if (value && !styleName.includes('animation') && !styleName.includes('-webkit') && _.isString(value)) { - const normalizedStyleName = styleName.replace(/-(.)/g, (a, b) => b.toUpperCase()); - normalizedStyle[styleName] = style[normalizedStyleName]; - if (style.getPropertyPriority(styleName) === 'important') { - normalizedStyle[styleName] += ' !important'; +function listGroupToTable($editable) { + for (const listGroup of $editable.find('.list-group')) { + const $listGroup = $(listGroup); + 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