diff --git a/.changeset/dirty-needles-chew.md b/.changeset/dirty-needles-chew.md new file mode 100644 index 0000000000..acc7dd42e2 --- /dev/null +++ b/.changeset/dirty-needles-chew.md @@ -0,0 +1,5 @@ +--- +"react-email": minor +--- + +Theme switcher for email template diff --git a/.changeset/flat-masks-take.md b/.changeset/flat-masks-take.md new file mode 100644 index 0000000000..27b4d1c380 --- /dev/null +++ b/.changeset/flat-masks-take.md @@ -0,0 +1,6 @@ +--- +"@react-email/preview-server": minor +"react-email": minor +--- + +Dark mode switcher emulating email client color inversion diff --git a/packages/preview-server/package.json b/packages/preview-server/package.json index b04f879099..f44c9710ba 100644 --- a/packages/preview-server/package.json +++ b/packages/preview-server/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@types/node": "22.14.1", @@ -32,6 +33,7 @@ "@types/webpack": "5.28.5", "autoprefixer": "10.4.21", "clsx": "2.1.1", + "colorjs.io": "0.5.2", "esbuild": "0.25.10", "framer-motion": "12.23.22", "json5": "2.2.3", @@ -59,6 +61,7 @@ "@react-email/components": "workspace:*", "@types/babel__core": "7.20.5", "@types/babel__traverse": "7.20.7", + "@types/color": "4.2.0", "@types/fs-extra": "11.0.1", "@types/mime-types": "2.1.4", "@types/node": "22.10.2", diff --git a/packages/preview-server/src/app/preview/[...slug]/email-frame.tsx b/packages/preview-server/src/app/preview/[...slug]/email-frame.tsx new file mode 100644 index 0000000000..d7b477eb6a --- /dev/null +++ b/packages/preview-server/src/app/preview/[...slug]/email-frame.tsx @@ -0,0 +1,138 @@ +import { Slot } from '@radix-ui/react-slot'; +import Color from 'colorjs.io'; +import type { ComponentProps } from 'react'; + +function* walkDom(element: Element): Generator { + if (element.children.length > 0) { + for (let i = 0; i < element.children.length; i++) { + const child = element.children.item(i)!; + yield child; + yield* walkDom(child); + } + } +} + +function invertColor(colorString: string, mode: 'foreground' | 'background') { + const color = new Color(colorString).to('lch'); + + if (mode === 'foreground') { + if (color.lch.l! < 50) { + color.lch.l = 100 - color.lch.l! * 0.75; + } + } else if (mode === 'background') { + if (color.lch.l! >= 50) { + color.lch.l = 100 - color.lch.l! * 0.75; + } + } + + color.lch.c! *= 0.8; + + return color.toString(); +} + +const colorRegex = + /#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6,8}|rgba?\(.*?\)|hsl\(.*?\)|hsv\(.*?\)|oklab\(.*?\)|oklch\(.*?\)/g; + +function applyColorInversion(iframe: HTMLIFrameElement) { + const { contentDocument, contentWindow } = iframe; + if (!contentDocument || !contentWindow) return; + + if (contentDocument.body.hasAttribute('inverted-colors')) return; + + contentDocument.body.setAttribute('inverted-colors', ''); + + if (!contentDocument.body.style.color) { + contentDocument.body.style.color = 'rgb(0, 0, 0)'; + } + + for (const element of walkDom(contentDocument.documentElement)) { + if ( + element instanceof + (contentWindow as unknown as typeof globalThis).HTMLElement + ) { + if (element.style.color) { + element.style.color = element.style.color.replaceAll( + colorRegex, + (color) => invertColor(color, 'foreground'), + ); + colorRegex.lastIndex = 0; + } + if (element.style.background) { + element.style.background = element.style.background.replaceAll( + colorRegex, + (color) => invertColor(color, 'background'), + ); + colorRegex.lastIndex = 0; + } + if (element.style.backgroundColor) { + element.style.backgroundColor = + element.style.backgroundColor.replaceAll(colorRegex, (color) => + invertColor(color, 'background'), + ); + colorRegex.lastIndex = 0; + } + if (element.style.borderColor) { + element.style.borderColor = element.style.borderColor.replaceAll( + colorRegex, + (color) => invertColor(color, 'background'), + ); + colorRegex.lastIndex = 0; + } + if (element.style.border) { + element.style.border = element.style.border.replaceAll( + colorRegex, + (color) => invertColor(color, 'background'), + ); + colorRegex.lastIndex = 0; + } + } + } +} + +interface EmailFrameProps extends ComponentProps<'iframe'> { + markup: string; + width: number; + height: number; + darkMode: boolean; +} + +export function EmailFrame({ + markup, + width, + height, + darkMode, + ...rest +}: EmailFrameProps) { + return ( + { + if (!iframe) return; + + if (darkMode) { + applyColorInversion(iframe); + } + + const handleLoad = () => { + if (darkMode) { + applyColorInversion(iframe); + } + }; + + iframe.addEventListener('load', handleLoad); + return () => { + iframe.removeEventListener('load', handleLoad); + }; + }} + > +