diff --git a/frontend/email-builder/.eslintrc.cjs b/frontend/email-builder/.eslintrc.cjs new file mode 100644 index 000000000..0c904bb92 --- /dev/null +++ b/frontend/email-builder/.eslintrc.cjs @@ -0,0 +1,26 @@ +module.exports = { + root: true, + env: { + browser: true, + node: true, + es2022: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ['@typescript-eslint', 'react-hooks', 'simple-import-sort'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', 'node_modules'], + rules: { + 'simple-import-sort/imports': 'off', + }, +}; diff --git a/frontend/email-builder/src/outlook.ts b/frontend/email-builder/src/outlook.ts new file mode 100644 index 000000000..1f7633ef2 --- /dev/null +++ b/frontend/email-builder/src/outlook.ts @@ -0,0 +1,370 @@ +type TStyleMap = Record; +type TPaddingValues = { + top: number; + right: number; + bottom: number; + left: number; +}; + +const PRESENTATION_TABLE_STYLE = 'border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;'; + +function appendMissingStyles(style: string | null, declarations: Array<[string, string]>) { + const current = (style || '').trim(); + const lower = current.toLowerCase(); + const missing = declarations + .filter(([property]) => !lower.includes(`${property.toLowerCase()}:`)) + .map(([property, value]) => `${property}:${value}`); + + if (missing.length === 0) { + return current; + } + + return [current.replace(/;+\s*$/, ''), ...missing] + .filter(Boolean) + .join(';'); +} + +function setStyleValues(style: string | null, declarations: Array<[string, string | null]>) { + const styleMap = parseStyleMap(style); + + declarations.forEach(([property, value]) => { + const key = property.toLowerCase(); + if (value === null || value === '') { + delete styleMap[key]; + return; + } + + styleMap[key] = value; + }); + + return Object.entries(styleMap) + .map(([property, value]) => `${property}:${value}`) + .join(';'); +} + +function parseStyleMap(style: string | null) { + return (style || '') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean) + .reduce((acc, entry) => { + const separator = entry.indexOf(':'); + if (separator === -1) { + return acc; + } + + const property = entry.slice(0, separator).trim().toLowerCase(); + const value = entry.slice(separator + 1).trim(); + if (property) { + acc[property] = value; + } + return acc; + }, {}); +} + +function getPixelValue(value?: string) { + if (!value) { + return null; + } + + const match = value.trim().match(/^(-?\d+(?:\.\d+)?)px$/i); + if (!match) { + return null; + } + + return Math.round(Number(match[1])); +} + +function getPixelWidthFromImage(img: HTMLImageElement) { + const attrWidth = img.getAttribute('width'); + if (attrWidth && /^\d+$/.test(attrWidth)) { + return attrWidth; + } + + const style = img.getAttribute('style') || ''; + const widthMatch = style.match(/(?:^|;)\s*width\s*:\s*(\d+)px(?:;|$)/i); + if (widthMatch) { + return widthMatch[1]; + } + + const maxWidthMatch = style.match(/(?:^|;)\s*max-width\s*:\s*(\d+)px(?:;|$)/i); + if (maxWidthMatch) { + return maxWidthMatch[1]; + } + + return null; +} + +function getPaddingValues(styleMap: TStyleMap): TPaddingValues { + const shorthand = styleMap.padding?.trim().split(/\s+/) || []; + + const [ + topFromShorthand, + rightFromShorthand = topFromShorthand, + bottomFromShorthand = topFromShorthand, + leftFromShorthand = rightFromShorthand, + ] = shorthand; + + return { + top: getPixelValue(styleMap['padding-top'] || topFromShorthand) || 0, + right: getPixelValue(styleMap['padding-right'] || rightFromShorthand) || 0, + bottom: getPixelValue(styleMap['padding-bottom'] || bottomFromShorthand) || 0, + left: getPixelValue(styleMap['padding-left'] || leftFromShorthand) || 0, + }; +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function escapeAttribute(value: string) { + return escapeHtml(value).replace(/"/g, '"'); +} + +function createFragmentFromHtml(node: Element, html: string) { + const range = node.ownerDocument.createRange(); + range.selectNode(node); + return range.createContextualFragment(html); +} + +function replaceNodeWithHtml(node: Element, html: string) { + node.replaceWith(createFragmentFromHtml(node, html)); +} + +function escapeTemplateString(value: string) { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); +} + +function makeSafeTemplate(raw: string) { + return `{{ Safe "${escapeTemplateString(raw)}" }}`; +} + +function getWrapperOptions(style: string | null) { + const styleValue = style || ''; + const styleMap = parseStyleMap(styleValue); + const align = styleMap['text-align'] || 'left'; + const backgroundColor = styleMap['background-color']; + const bgcolorAttr = backgroundColor ? ` bgcolor="${escapeAttribute(backgroundColor)}"` : ''; + + return { styleValue, styleMap, align, bgcolorAttr }; +} + +function buildPresentationTable(contents: string, width: string = '100%') { + return `${contents}
`; +} + +function hasSingleChildMatching(div: HTMLDivElement, predicate: (child: Element) => boolean) { + const children = Array.from(div.children); + return children.length === 1 && predicate(children[0]); +} + +function addTableDefaults(doc: Document) { + doc.querySelectorAll('table').forEach((table) => { + if (!table.getAttribute('role')) { + table.setAttribute('role', 'presentation'); + } + if (!table.getAttribute('cellpadding')) { + table.setAttribute('cellpadding', '0'); + } + if (!table.getAttribute('cellspacing')) { + table.setAttribute('cellspacing', '0'); + } + if (!table.getAttribute('border')) { + table.setAttribute('border', '0'); + } + + table.setAttribute( + 'style', + appendMissingStyles(table.getAttribute('style'), [ + ['border-collapse', 'collapse'], + ['mso-table-lspace', '0pt'], + ['mso-table-rspace', '0pt'], + ]) + ); + }); +} + +function hardenImages(doc: Document) { + doc.querySelectorAll('img').forEach((img) => { + img.setAttribute('border', '0'); + + const width = getPixelWidthFromImage(img); + if (width && !img.getAttribute('width')) { + img.setAttribute('width', width); + } + + img.setAttribute('style', setStyleValues(img.getAttribute('style'), [ + ['display', 'block'], + ['border', '0'], + ['outline', 'none'], + ['text-decoration', 'none'], + ['height', 'auto'], + ['-ms-interpolation-mode', 'bicubic'], + ['vertical-align', null], + ])); + + const parent = img.parentElement; + if (parent?.tagName === 'A') { + parent.setAttribute('style', setStyleValues(parent.getAttribute('style'), [ + ['display', 'inline-block'], + ['border', '0'], + ['text-decoration', 'none'], + ])); + } + }); +} + +function transformImageBlocks(doc: Document) { + const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => hasSingleChildMatching(div as HTMLDivElement, (child) => { + if (child.tagName === 'IMG') { + return true; + } + + return child.tagName === 'A' && child.children.length === 1 && child.querySelector('img') !== null; + })) as HTMLDivElement[]; + + wrappers.forEach((div) => { + const { styleValue, align, bgcolorAttr } = getWrapperOptions(div.getAttribute('style')); + const content = div.innerHTML; + + const innerTable = buildPresentationTable(`${content}`, 'auto'); + const html = buildPresentationTable( + `${innerTable}` + ); + + replaceNodeWithHtml(div, html); + }); +} + +function transformSimpleDivBlocks(doc: Document) { + const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => { + const { styleMap } = getWrapperOptions(div.getAttribute('style')); + + if (!styleMap.padding && !styleMap.height) { + return false; + } + + if (div.children.length > 0) { + const firstChild = div.children[0]; + if (firstChild.tagName === 'A' || firstChild.tagName === 'IMG' || firstChild.tagName === 'TABLE') { + return false; + } + } + + if (styleMap['min-height'] && styleMap.width === '100%') { + return false; + } + + return true; + }) as HTMLDivElement[]; + + wrappers.forEach((div) => { + const { styleValue, styleMap, align, bgcolorAttr } = getWrapperOptions(div.getAttribute('style')); + const height = getPixelValue(styleMap.height); + const isSpacer = div.children.length === 0 && (div.textContent || '').trim() === '' && height !== null; + + if (isSpacer) { + const spacerHtml = buildPresentationTable( + ` ` + ); + + replaceNodeWithHtml(div, spacerHtml); + return; + } + + const blockHtml = buildPresentationTable( + `${div.innerHTML}` + ); + + replaceNodeWithHtml(div, blockHtml); + }); +} + +function buildBulletproofButton(anchor: HTMLAnchorElement, wrapperStyle: string) { + const anchorStyleMap = parseStyleMap(anchor.getAttribute('style')); + const wrapperStyleMap = parseStyleMap(wrapperStyle); + const text = anchor.textContent?.replace(/\s+/g, ' ').trim() || ''; + const href = anchor.getAttribute('href') || '#'; + const target = anchor.getAttribute('target'); + const align = wrapperStyleMap['text-align'] || 'left'; + const buttonColor = anchorStyleMap['background-color'] || '#0055d4'; + const textColor = anchorStyleMap.color || '#ffffff'; + const fontSize = getPixelValue(anchorStyleMap['font-size']) || 16; + const fontWeight = anchorStyleMap['font-weight'] || 'bold'; + const fontFamily = anchorStyleMap['font-family'] || 'Arial, sans-serif'; + const borderRadius = getPixelValue(anchorStyleMap['border-radius']) || 0; + const paddingValues = getPaddingValues(anchorStyleMap); + const lineHeight = getPixelValue(anchorStyleMap['line-height']) || Math.round(fontSize * 1.2); + const display = (anchorStyleMap.display || '').toLowerCase(); + const fullWidth = display === 'block' || anchorStyleMap.width === '100%'; + + const targetAttr = target ? ` target="${escapeAttribute(target)}"` : ''; + + if (fullWidth) { + const anchorStyle = appendMissingStyles(anchor.getAttribute('style'), [ + ['display', 'block'], + ['text-align', 'center'], + ['border', '1px solid ' + buttonColor], + ]); + + return [ + buildPresentationTable( + `${buildPresentationTable( + `${escapeHtml(text)}` + )}` + ), + ].join(''); + } + + const estimatedTextWidth = Math.max(1, Math.round(text.length * fontSize * (fontWeight.toLowerCase() === 'bold' ? 0.68 : 0.62))); + const estimatedWidth = Math.max(40, estimatedTextWidth + paddingValues.left + paddingValues.right); + const estimatedHeight = Math.max(lineHeight + paddingValues.top + paddingValues.bottom, 32); + const arcsize = Math.max(0, Math.min(50, Math.round((borderRadius / estimatedHeight) * 100))); + const cleanAnchorStyle = anchor.getAttribute('style') || ''; + const vml = makeSafeTemplate(``); + const nonMsoStart = makeSafeTemplate(''); + const nonMsoEnd = makeSafeTemplate(''); + + return buildPresentationTable( + `${vml}${nonMsoStart}${escapeHtml(text)}${nonMsoEnd}` + ); +} + +function transformButtonBlocks(doc: Document) { + const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => hasSingleChildMatching(div as HTMLDivElement, (child) => { + if (child.tagName !== 'A' || child.querySelector('img')) { + return false; + } + + const styleMap = parseStyleMap((child as HTMLAnchorElement).getAttribute('style')); + return Boolean(styleMap['background-color'] && styleMap.padding); + })) as HTMLDivElement[]; + + wrappers.forEach((div) => { + const anchor = div.children[0] as HTMLAnchorElement; + replaceNodeWithHtml(div, buildBulletproofButton(anchor, div.getAttribute('style') || '')); + }); +} + +export function postProcessForOutlook(html: string) { + if (typeof DOMParser === 'undefined') { + return html; + } + + const doc = new DOMParser().parseFromString(html, 'text/html'); + + addTableDefaults(doc); + hardenImages(doc); + transformButtonBlocks(doc); + transformImageBlocks(doc); + transformSimpleDivBlocks(doc); + addTableDefaults(doc); + hardenImages(doc); + + return `\n${doc.documentElement.outerHTML}`; +} diff --git a/frontend/email-builder/src/utils.tsx b/frontend/email-builder/src/utils.tsx index c759493a7..e6ee2f874 100644 --- a/frontend/email-builder/src/utils.tsx +++ b/frontend/email-builder/src/utils.tsx @@ -1,11 +1,15 @@ import { renderToStaticMarkup } from '@usewaypoint/email-builder'; import { TEditorConfiguration } from './documents/editor/core'; +import { postProcessForOutlook } from './outlook'; + +const VIEWPORT_META = ''; +const MSO_DOCUMENT_SETTINGS = ''; export function renderHtmlWithMeta(document: TEditorConfiguration, options: { rootBlockId: string }): string { - const html = renderToStaticMarkup(document, options); - // Insert with viewport meta after + const html = postProcessForOutlook(renderToStaticMarkup(document, options)); + return html.replace( - '', - '' + /]*)>/i, + `${VIEWPORT_META}${MSO_DOCUMENT_SETTINGS}` ); } diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue index d1205a0fb..e68d17dce 100644 --- a/frontend/src/components/Editor.vue +++ b/frontend/src/components/Editor.vue @@ -130,6 +130,8 @@ export default { contentTypeSel: this.$props.value.contentType, templateId: null, visualTemplateId: null, + visualSnapshotBody: this.$props.value.contentType === 'visual' ? (this.$props.value.body || '') : null, + visualSnapshotSource: this.$props.value.contentType === 'visual' ? this.$props.value.bodySource : null, }; }, @@ -167,6 +169,11 @@ export default { d.innerHTML = body; body = this.beautifyHTML(d.innerHTML.trim()); isHTML = true; + + if (from === 'visual') { + this.visualSnapshotBody = body; + this.visualSnapshotSource = this.self.bodySource; + } } // HTML => Non-HTML. @@ -185,8 +192,12 @@ export default { } case 'visual': { - const md = turndown.turndown(body).replace(/\n\n+/ig, '\n\n'); - bodySource = JSON.stringify(markdownToVisualBlock(md)); + if (this.visualSnapshotSource && this.visualSnapshotBody === body) { + bodySource = this.visualSnapshotSource; + } else { + const md = turndown.turndown(body).replace(/\n\n+/ig, '\n\n'); + bodySource = JSON.stringify(markdownToVisualBlock(md)); + } break; } @@ -215,7 +226,11 @@ export default { } else if (from === 'plain' && (to === 'richtext' || to === 'html')) { body = body.replace(/\n/ig, '
\n'); } else if (to === 'visual') { - bodySource = JSON.stringify(markdownToVisualBlock(body)); + if (this.visualSnapshotSource && this.visualSnapshotBody === body) { + bodySource = this.visualSnapshotSource; + } else { + bodySource = JSON.stringify(markdownToVisualBlock(body)); + } } // ======================================================================= @@ -259,6 +274,8 @@ export default { onVisualEditorChange({ body, source }) { this.self.body = body; this.self.bodySource = source; + this.visualSnapshotBody = body; + this.visualSnapshotSource = source; }, beautifyHTML(str) { @@ -306,6 +323,8 @@ export default { this.$api.getTemplate(this.visualTemplateId).then((data) => { this.self.body = data.body; this.self.bodySource = data.bodySource; + this.visualSnapshotBody = data.body; + this.visualSnapshotSource = data.bodySource; this.isVisualTplDisabled = true; this.$refs.visualEditor.render(JSON.parse(data.bodySource)); @@ -333,6 +352,11 @@ export default { this.contentTypeSel = this.value.contentType; this.templateId = this.value.templateId; + if (this.value.contentType === 'visual') { + this.visualSnapshotBody = this.value.body || ''; + this.visualSnapshotSource = this.value.bodySource; + } + window.addEventListener('keydown', this.onKeyboardShortcut); this.$events.$on('campaign.preview', () => {