|
41 | 41 | </el-alert> |
42 | 42 |
|
43 | 43 | <div class="fill" style="display: flex; flex-direction: column;"> |
44 | | - <iframe class="htmlview" :style="htmlFrameStyles" @load="onHtmlFrameLoaded" ref="htmlframe"></iframe> |
| 44 | + <iframe :class="htmlFrameClasses" :style="htmlFrameStyles" @load="onHtmlFrameLoaded" ref="htmlframe"></iframe> |
45 | 45 | </div> |
46 | 46 | </div> |
47 | 47 | </template> |
|
54 | 54 | import * as srcDoc from 'srcdoc-polyfill'; |
55 | 55 | import sanitizeHtml from 'sanitize-html'; |
56 | 56 | import { deviceSizes, Brand } from 'device-sizes' |
| 57 | + import * as csstree from 'css-tree'; |
57 | 58 |
|
58 | 59 | type ViewPortSize = { |
59 | 60 | name: string |
|
79 | 80 | enableSanitization = true; |
80 | 81 | sanitizedHtml: string | null = null; |
81 | 82 | wasSanitized: boolean = false; |
| 83 | + emailSupportsDarkMode: boolean = false; |
82 | 84 |
|
83 | 85 | availableViewportSizes: ViewPortSize[] = [{ name: "Normal", fill: true }].concat(Object.values(deviceSizes).map(d => ({ |
84 | 86 | name: `${Brand[d.brand]} ${d.name} (${d.size}")`, fill: false, width: d.width / d.scale, height: d.height / d.scale |
|
113 | 115 | }; |
114 | 116 | } |
115 | 117 |
|
| 118 | + get htmlFrameClasses() { |
| 119 | + return { |
| 120 | + 'htmlview': true, |
| 121 | + 'supports-dark-mode': this.emailSupportsDarkMode |
| 122 | + }; |
| 123 | + } |
| 124 | +
|
116 | 125 |
|
117 | 126 | error: Error | null = null; |
118 | 127 | loading = false; |
|
134 | 143 | private updateIframe() { |
135 | 144 | this.wasSanitized = false; |
136 | 145 | this.sanitizedHtml = ""; |
| 146 | + this.emailSupportsDarkMode = false; // Reset first |
137 | 147 |
|
138 | 148 | if (this.html) { |
| 149 | + // Check if email supports dark mode before sanitization |
| 150 | + const originalDarkModeSupport = this.detectDarkModeSupport(this.html); |
| 151 | + console.log('Dark mode detection on original HTML:', originalDarkModeSupport, 'for HTML length:', this.html.length); |
| 152 | +
|
139 | 153 | if (!this.enableSanitization) { |
140 | 154 | this.sanitizedHtml = this.html; |
| 155 | + this.emailSupportsDarkMode = originalDarkModeSupport; |
141 | 156 | } else { |
142 | | - this.sanitizedHtml = sanitizeHtml(this.html, { allowedTags: sanitizeHtml.defaults.allowedTags.concat("img"), allowedSchemesByTag: { "img": ["cid", "data"] } }); |
| 157 | + // Allow additional tags and attributes needed for dark mode detection |
| 158 | + const sanitizeOptions = { |
| 159 | + allowedTags: sanitizeHtml.defaults.allowedTags.concat([ |
| 160 | + "img", "style", "meta", "head", "html", "body" |
| 161 | + ]), |
| 162 | + allowedAttributes: { |
| 163 | + ...sanitizeHtml.defaults.allowedAttributes, |
| 164 | + "meta": ["name", "content", "charset", "http-equiv"], |
| 165 | + "html": ["lang", "dir"], |
| 166 | + "body": ["class"], |
| 167 | + "*": ["style", "class", "id"] // Allow style, class, and id on all elements for better CSS support |
| 168 | + }, |
| 169 | + allowedSchemesByTag: { |
| 170 | + "img": ["cid", "data"] |
| 171 | + } |
| 172 | + }; |
| 173 | + |
| 174 | + this.sanitizedHtml = sanitizeHtml(this.html, sanitizeOptions); |
143 | 175 | let normalizedOriginalHtml = sanitizeHtml(this.html, { allowedAttributes: false, allowedTags: false, allowVulnerableTags: true }); |
144 | 176 | this.wasSanitized = normalizedOriginalHtml !== this.sanitizedHtml; |
| 177 | + |
| 178 | + // Check dark mode support on sanitized HTML |
| 179 | + this.emailSupportsDarkMode = this.detectDarkModeSupport(this.sanitizedHtml); |
| 180 | + console.log('Dark mode detection on sanitized HTML:', this.emailSupportsDarkMode, 'for sanitized HTML length:', this.sanitizedHtml.length); |
| 181 | + |
| 182 | + if (originalDarkModeSupport !== this.emailSupportsDarkMode) { |
| 183 | + console.warn('⚠️ Dark mode detection result changed after sanitization!', |
| 184 | + 'Original:', originalDarkModeSupport, 'Sanitized:', this.emailSupportsDarkMode); |
| 185 | + } |
145 | 186 | } |
146 | 187 | } |
147 | 188 |
|
148 | 189 | srcDoc.set(this.$refs.htmlframe as HTMLIFrameElement, this.sanitizedHtml); |
149 | 190 | } |
150 | 191 |
|
| 192 | + private detectDarkModeSupport(html: string): boolean { |
| 193 | + try { |
| 194 | + console.log('Detecting dark mode support in HTML...'); |
| 195 | + |
| 196 | + // Use DOMParser to safely parse HTML |
| 197 | + const parser = new DOMParser(); |
| 198 | + const doc = parser.parseFromString(html, 'text/html'); |
| 199 | +
|
| 200 | + // Check for supported-color-schemes meta tag |
| 201 | + const supportedColorSchemesMeta = doc.querySelector('meta[name="supported-color-schemes"]'); |
| 202 | + if (supportedColorSchemesMeta) { |
| 203 | + const content = supportedColorSchemesMeta.getAttribute('content') || ''; |
| 204 | + console.log('Found supported-color-schemes meta tag with content:', content); |
| 205 | + if (this.parseColorSchemeValues(content).includes('dark')) { |
| 206 | + console.log('✅ Dark mode detected via supported-color-schemes meta tag'); |
| 207 | + return true; |
| 208 | + } |
| 209 | + } |
| 210 | +
|
| 211 | + // Check for color-scheme meta tag |
| 212 | + const colorSchemeMeta = doc.querySelector('meta[name="color-scheme"]'); |
| 213 | + if (colorSchemeMeta) { |
| 214 | + const content = colorSchemeMeta.getAttribute('content') || ''; |
| 215 | + console.log('Found color-scheme meta tag with content:', content); |
| 216 | + if (this.parseColorSchemeValues(content).includes('dark')) { |
| 217 | + console.log('✅ Dark mode detected via color-scheme meta tag'); |
| 218 | + return true; |
| 219 | + } |
| 220 | + } |
| 221 | +
|
| 222 | + // Check for CSS media queries that indicate dark mode support |
| 223 | + const styleElements = doc.querySelectorAll('style'); |
| 224 | + console.log('Found', styleElements.length, 'style elements'); |
| 225 | + for (const styleElement of styleElements) { |
| 226 | + const cssText = styleElement.textContent || ''; |
| 227 | + if (cssText && this.parseCSSForDarkModeQueries(cssText)) { |
| 228 | + console.log('✅ Dark mode detected via CSS media query'); |
| 229 | + return true; |
| 230 | + } |
| 231 | + } |
| 232 | +
|
| 233 | + // Also check for dark mode media queries in linked stylesheets or inline styles |
| 234 | + // Note: We can't access external stylesheets due to CORS, but we can check style attributes |
| 235 | + const elementsWithStyle = doc.querySelectorAll('[style]'); |
| 236 | + for (const element of elementsWithStyle) { |
| 237 | + const styleAttr = element.getAttribute('style') || ''; |
| 238 | + if (this.parseCSSForDarkModeQueries(styleAttr)) { |
| 239 | + console.log('✅ Dark mode detected via inline style'); |
| 240 | + return true; |
| 241 | + } |
| 242 | + } |
| 243 | +
|
| 244 | + console.log('❌ No dark mode support detected'); |
| 245 | + return false; |
| 246 | + } catch (error) { |
| 247 | + console.warn('Error parsing HTML for dark mode detection:', error); |
| 248 | + return false; |
| 249 | + } |
| 250 | + } |
| 251 | +
|
| 252 | + private parseColorSchemeValues(content: string): string[] { |
| 253 | + if (!content) return []; |
| 254 | + |
| 255 | + // Split by both spaces and commas, trim each value, and filter out empty values |
| 256 | + return content |
| 257 | + .split(/[\s,]+/) |
| 258 | + .map(value => value.trim().toLowerCase()) |
| 259 | + .filter(value => value.length > 0); |
| 260 | + } |
| 261 | +
|
| 262 | + private parseCSSForDarkModeQueries(cssText: string): boolean { |
| 263 | + if (!cssText) return false; |
| 264 | +
|
| 265 | + // First try simple string search as it's more reliable for this use case |
| 266 | + const simpleCheck = cssText.toLowerCase().includes('prefers-color-scheme') && cssText.toLowerCase().includes('dark'); |
| 267 | + if (simpleCheck) { |
| 268 | + console.log('✅ Found dark mode media query via simple string search'); |
| 269 | + return true; |
| 270 | + } |
| 271 | +
|
| 272 | + try { |
| 273 | + // Try css-tree parsing as a secondary check |
| 274 | + const ast = csstree.parse(cssText, { parseRulePrelude: false }); |
| 275 | + |
| 276 | + let foundDarkModeQuery = false; |
| 277 | + |
| 278 | + csstree.walk(ast, function(node) { |
| 279 | + if (node.type === 'Atrule' && node.name === 'media') { |
| 280 | + const mediaQueryText = csstree.generate(node.prelude); |
| 281 | + console.log('Found @media rule:', mediaQueryText); |
| 282 | + if (mediaQueryText.includes('prefers-color-scheme') && mediaQueryText.includes('dark')) { |
| 283 | + console.log('✅ Found dark mode media query via css-tree:', mediaQueryText); |
| 284 | + foundDarkModeQuery = true; |
| 285 | + } |
| 286 | + } |
| 287 | + }); |
| 288 | +
|
| 289 | + return foundDarkModeQuery; |
| 290 | + } catch (error) { |
| 291 | + console.warn('Error parsing CSS with css-tree, using fallback:', error); |
| 292 | + return simpleCheck; |
| 293 | + } |
| 294 | + } |
| 295 | +
|
| 296 | + private checkMediaQueryForDarkMode(mediaQuery: any): boolean { |
| 297 | + // This method is no longer used, keeping for compatibility |
| 298 | + return false; |
| 299 | + } |
| 300 | +
|
151 | 301 | async onHtmlFrameLoaded() { |
152 | 302 | var doc = (this.$refs.htmlframe as HTMLIFrameElement).contentDocument; |
153 | 303 | if (!doc) { |
|
0 commit comments