Skip to content

Commit 098fc8a

Browse files
author
Timo Dahlenburg
committed
Apply hodor suggestion
1 parent bcb7be6 commit 098fc8a

6 files changed

Lines changed: 109 additions & 29 deletions

File tree

frontend/email-builder/src/App/TemplatePanel/HtmlPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ import HighlightedCodePanel from './helper/HighlightedCodePanel';
77

88
export default function HtmlPanel() {
99
const document = useDocument();
10-
const code = useMemo(() => renderHtmlWithMeta(document, { rootBlockId: 'root' }), [document]);
10+
const code = useMemo(() => renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: true }), [document]);
1111
return <HighlightedCodePanel type="html" value={code} />;
1212
}

frontend/email-builder/src/main.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import ReactDOM from 'react-dom/client';
33
import App, { AppProps, DEFAULT_SOURCE } from './App';
44
import { setDocument, resetDocument } from './documents/editor/EditorContext';
5+
import { renderHtmlWithMeta } from './utils';
56

67
import { CssBaseline, ThemeProvider } from '@mui/material';
78
import theme from './theme';
@@ -31,4 +32,4 @@ function render(containerId: string, props: AppProps, force: boolean = false) {
3132
}
3233
}
3334

34-
export { App, setDocument, resetDocument, render, isRendered, DEFAULT_SOURCE };
35+
export { App, setDocument, resetDocument, render, isRendered, DEFAULT_SOURCE, renderHtmlWithMeta };

frontend/email-builder/src/outlook.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,13 @@ function escapeTemplateString(value: string) {
141141
}
142142

143143
function makeSafeTemplate(raw: string) {
144-
return `{{ Safe "${escapeTemplateString(raw)}" }}`;
144+
// Encode angle brackets so DOMParser does not consume Outlook conditional comments
145+
// before the Go template expression is evaluated.
146+
const escaped = escapeTemplateString(raw)
147+
.replace(/</g, '\\x3c')
148+
.replace(/>/g, '\\x3e');
149+
150+
return `{{ Safe "${escaped}" }}`;
145151
}
146152

147153
function getWrapperOptions(style: string | null) {
@@ -155,7 +161,9 @@ function getWrapperOptions(style: string | null) {
155161
}
156162

157163
function buildPresentationTable(contents: string, width: string = '100%') {
158-
return `<table role="presentation" width="${width}" cellpadding="0" cellspacing="0" border="0" style="${PRESENTATION_TABLE_STYLE}">${contents}</table>`;
164+
const widthAttr = width && width !== 'auto' ? ` width="${escapeAttribute(width)}"` : '';
165+
166+
return `<table role="presentation"${widthAttr} cellpadding="0" cellspacing="0" border="0" style="${PRESENTATION_TABLE_STYLE}">${contents}</table>`;
159167
}
160168

161169
function hasSingleChildMatching(div: HTMLDivElement, predicate: (child: Element) => boolean) {
@@ -164,10 +172,7 @@ function hasSingleChildMatching(div: HTMLDivElement, predicate: (child: Element)
164172
}
165173

166174
function addTableDefaults(doc: Document) {
167-
doc.querySelectorAll('table').forEach((table) => {
168-
if (!table.getAttribute('role')) {
169-
table.setAttribute('role', 'presentation');
170-
}
175+
doc.querySelectorAll('table[role="presentation"]').forEach((table) => {
171176
if (!table.getAttribute('cellpadding')) {
172177
table.setAttribute('cellpadding', '0');
173178
}
@@ -189,6 +194,25 @@ function addTableDefaults(doc: Document) {
189194
});
190195
}
191196

197+
function isStandaloneImage(img: HTMLImageElement) {
198+
const parent = img.parentElement;
199+
if (!parent) {
200+
return false;
201+
}
202+
203+
if (parent.tagName === 'DIV') {
204+
return hasSingleChildMatching(parent as HTMLDivElement, (child) => child.tagName === 'IMG');
205+
}
206+
207+
if (parent.tagName === 'A' && parent.children.length === 1) {
208+
const grandparent = parent.parentElement;
209+
return grandparent?.tagName === 'DIV'
210+
&& hasSingleChildMatching(grandparent as HTMLDivElement, (child) => child.tagName === 'A');
211+
}
212+
213+
return false;
214+
}
215+
192216
function hardenImages(doc: Document) {
193217
doc.querySelectorAll('img').forEach((img) => {
194218
img.setAttribute('border', '0');
@@ -198,18 +222,24 @@ function hardenImages(doc: Document) {
198222
img.setAttribute('width', width);
199223
}
200224

201-
img.setAttribute('style', setStyleValues(img.getAttribute('style'), [
202-
['display', 'block'],
225+
const standaloneImage = isStandaloneImage(img);
226+
const declarations: Array<[string, string | null]> = [
203227
['border', '0'],
204228
['outline', 'none'],
205229
['text-decoration', 'none'],
206230
['height', 'auto'],
207231
['-ms-interpolation-mode', 'bicubic'],
208-
['vertical-align', null],
209-
]));
232+
];
233+
234+
if (standaloneImage) {
235+
declarations.unshift(['display', 'block']);
236+
declarations.push(['vertical-align', null]);
237+
}
238+
239+
img.setAttribute('style', setStyleValues(img.getAttribute('style'), declarations));
210240

211241
const parent = img.parentElement;
212-
if (parent?.tagName === 'A') {
242+
if (standaloneImage && parent?.tagName === 'A') {
213243
parent.setAttribute('style', setStyleValues(parent.getAttribute('style'), [
214244
['display', 'inline-block'],
215245
['border', '0'],
@@ -306,7 +336,7 @@ function buildBulletproofButton(anchor: HTMLAnchorElement, wrapperStyle: string)
306336
const targetAttr = target ? ` target="${escapeAttribute(target)}"` : '';
307337

308338
if (fullWidth) {
309-
const anchorStyle = appendMissingStyles(anchor.getAttribute('style'), [
339+
const anchorStyle = setStyleValues(anchor.getAttribute('style'), [
310340
['display', 'block'],
311341
['text-align', 'center'],
312342
['border', '1px solid ' + buttonColor],
@@ -326,12 +356,14 @@ function buildBulletproofButton(anchor: HTMLAnchorElement, wrapperStyle: string)
326356
const estimatedHeight = Math.max(lineHeight + paddingValues.top + paddingValues.bottom, 32);
327357
const arcsize = Math.max(0, Math.min(50, Math.round((borderRadius / estimatedHeight) * 100)));
328358
const cleanAnchorStyle = anchor.getAttribute('style') || '';
329-
const vml = makeSafeTemplate(`<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeAttribute(href)}" style="height:${estimatedHeight}px;v-text-anchor:middle;width:${estimatedWidth}px;" arcsize="${arcsize}%" strokecolor="${escapeAttribute(buttonColor)}" fillcolor="${escapeAttribute(buttonColor)}"><w:anchorlock/><center style="color:${escapeAttribute(textColor)};font-family:${escapeAttribute(fontFamily)};font-size:${fontSize}px;font-weight:${escapeAttribute(fontWeight)};">${escapeHtml(text)}</center></v:roundrect><![endif]-->`);
359+
const msoStart = makeSafeTemplate('<!--[if mso]>');
360+
const msoEnd = makeSafeTemplate('<![endif]-->');
361+
const vml = `<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeAttribute(href)}" style="height:${estimatedHeight}px;v-text-anchor:middle;width:${estimatedWidth}px;" arcsize="${arcsize}%" strokecolor="${escapeAttribute(buttonColor)}" fillcolor="${escapeAttribute(buttonColor)}"><w:anchorlock/><center style="color:${escapeAttribute(textColor)};font-family:${escapeAttribute(fontFamily)};font-size:${fontSize}px;font-weight:${escapeAttribute(fontWeight)};">${escapeHtml(text)}</center></v:roundrect>`;
330362
const nonMsoStart = makeSafeTemplate('<!--[if !mso]><!-->');
331363
const nonMsoEnd = makeSafeTemplate('<!--<![endif]-->');
332364

333365
return buildPresentationTable(
334-
`<tbody><tr><td align="${escapeAttribute(align)}" style="${escapeAttribute(wrapperStyle)}">${vml}${nonMsoStart}<a href="${escapeAttribute(href)}"${targetAttr} style="${escapeAttribute(cleanAnchorStyle)}">${escapeHtml(text)}</a>${nonMsoEnd}</td></tr></tbody>`
366+
`<tbody><tr><td align="${escapeAttribute(align)}" style="${escapeAttribute(wrapperStyle)}">${msoStart}${vml}${msoEnd}${nonMsoStart}<a href="${escapeAttribute(href)}"${targetAttr} style="${escapeAttribute(cleanAnchorStyle)}">${escapeHtml(text)}</a>${nonMsoEnd}</td></tr></tbody>`
335367
);
336368
}
337369

frontend/email-builder/src/utils.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,28 @@ import { TEditorConfiguration } from './documents/editor/core';
33
import { postProcessForOutlook } from './outlook';
44

55
const VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
6-
const MSO_DOCUMENT_SETTINGS = '<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->';
6+
const MSO_DOCUMENT_SETTINGS = '<!--[if mso]><noscript><xml xmlns:o="urn:schemas-microsoft-com:office:office"><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->';
77

8-
export function renderHtmlWithMeta(document: TEditorConfiguration, options: { rootBlockId: string }): string {
9-
const html = postProcessForOutlook(renderToStaticMarkup(document, options));
8+
function injectHeadContents(html: string, contents: string) {
9+
const headMatch = html.match(/<head\b([^>]*)>/i);
10+
if (headMatch) {
11+
return html.replace(/<head\b([^>]*)>/i, `<head$1>${contents}`);
12+
}
1013

11-
return html.replace(
12-
/<head([^>]*)>/i,
13-
`<head$1>${VIEWPORT_META}${MSO_DOCUMENT_SETTINGS}`
14-
);
14+
const htmlMatch = html.match(/<html\b([^>]*)>/i);
15+
if (htmlMatch) {
16+
return html.replace(/<html\b([^>]*)>/i, `<html$1><head>${contents}</head>`);
17+
}
18+
19+
return `<head>${contents}</head>${html}`;
20+
}
21+
22+
export function renderHtmlWithMeta(
23+
document: TEditorConfiguration,
24+
options: { rootBlockId: string; outlook?: boolean }
25+
): string {
26+
const html = renderToStaticMarkup(document, options);
27+
const output = options.outlook ? postProcessForOutlook(html) : html;
28+
29+
return injectHeadContents(output, `${VIEWPORT_META}${MSO_DOCUMENT_SETTINGS}`);
1530
}

frontend/src/components/Editor.vue

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,17 @@ export default {
136136
},
137137
138138
methods: {
139+
syncVisualSnapshot(value) {
140+
if (value.contentType === 'visual') {
141+
this.visualSnapshotBody = value.body || '';
142+
this.visualSnapshotSource = value.bodySource;
143+
return;
144+
}
145+
146+
this.visualSnapshotBody = null;
147+
this.visualSnapshotSource = null;
148+
},
149+
139150
onContentTypeChange(to, from) {
140151
if (!this.self.body.trim()) {
141152
this.convertContentType(to, from);
@@ -351,11 +362,7 @@ export default {
351362
// Set initial content type for the selector.
352363
this.contentTypeSel = this.value.contentType;
353364
this.templateId = this.value.templateId;
354-
355-
if (this.value.contentType === 'visual') {
356-
this.visualSnapshotBody = this.value.body || '';
357-
this.visualSnapshotSource = this.value.bodySource;
358-
}
365+
this.syncVisualSnapshot(this.value);
359366
360367
window.addEventListener('keydown', this.onKeyboardShortcut);
361368
@@ -392,6 +399,12 @@ export default {
392399
},
393400
394401
watch: {
402+
value(to) {
403+
this.contentTypeSel = to.contentType;
404+
this.templateId = to.templateId;
405+
this.syncVisualSnapshot(to);
406+
},
407+
395408
validTemplates() {
396409
// When the filtered list of validTemplates changes (visual vs. regular),
397410
// select the appropriate 'default' in the template select list.

frontend/src/components/VisualEditor.vue

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,26 @@ export default {
3030
data() {
3131
return {
3232
isMediaVisible: false,
33+
changeTimer: null,
3334
};
3435
},
3536
3637
methods: {
38+
emitVisualChange(data, body) {
39+
if (this.changeTimer) {
40+
window.clearTimeout(this.changeTimer);
41+
}
42+
43+
this.changeTimer = window.setTimeout(() => {
44+
const iframe = this.$refs.visualEditor;
45+
const renderHtml = iframe.contentWindow.EmailBuilder?.renderHtmlWithMeta;
46+
const processedBody = typeof renderHtml === 'function'
47+
? renderHtml(data, { rootBlockId: 'root', outlook: true })
48+
: body;
49+
this.$emit('change', { source: JSON.stringify(data), body: processedBody });
50+
}, 150);
51+
},
52+
3753
loadScript() {
3854
return new Promise((resolve, reject) => {
3955
const iframe = this.$refs.visualEditor;
@@ -67,7 +83,7 @@ export default {
6783
onChange: (data, body) => {
6884
// Hack to fix quotes in Go {{ templating }} in the HTML body.
6985
const tpl = body.replace(/\{\{[^}]*\}\}/g, (match) => match.replace(/&quot;/g, '"'));
70-
this.$emit('change', { source: JSON.stringify(data), body: tpl });
86+
this.emitVisualChange(data, tpl);
7187
},
7288
});
7389
}
@@ -165,6 +181,9 @@ export default {
165181
},
166182
167183
unmounted() {
184+
if (this.changeTimer) {
185+
window.clearTimeout(this.changeTimer);
186+
}
168187
window.removeEventListener('message', this.onSidebarMount, false);
169188
},
170189
};

0 commit comments

Comments
 (0)