diff --git a/apps/web/__tests__/EmailEditor/modeGuards.test.ts b/apps/web/__tests__/EmailEditor/modeGuards.test.ts
new file mode 100644
index 00000000..e13bc29d
--- /dev/null
+++ b/apps/web/__tests__/EmailEditor/modeGuards.test.ts
@@ -0,0 +1,41 @@
+import {describe, expect, it} from 'vitest';
+import {getInitialEditorMode, getModeToggleDecision} from '../../src/components/EmailEditor/modeGuards';
+
+describe('EmailEditor mode guards', () => {
+ it('starts in html mode for custom html templates', () => {
+ const customHtml = '
';
+
+ expect(getInitialEditorMode(customHtml)).toBe('html');
+ });
+
+ it('allows switching simple html into visual mode without snapshot', () => {
+ const simpleHtml = 'Hello world
';
+
+ expect(
+ getModeToggleDecision({
+ currentMode: 'html',
+ htmlContent: simpleHtml,
+ }),
+ ).toEqual({action: 'switch', nextMode: 'visual'});
+ });
+
+ it('allows switching custom html into visual mode with snapshot for revert', () => {
+ const customHtml = '';
+
+ const decision = getModeToggleDecision({
+ currentMode: 'html',
+ htmlContent: customHtml,
+ });
+
+ expect(decision).toEqual({action: 'switch', nextMode: 'visual', snapshot: customHtml});
+ });
+
+ it('always allows switching from visual to html', () => {
+ expect(
+ getModeToggleDecision({
+ currentMode: 'visual',
+ htmlContent: 'anything
',
+ }),
+ ).toEqual({action: 'switch', nextMode: 'html'});
+ });
+});
diff --git a/apps/web/src/components/EmailEditor/EmailEditor.tsx b/apps/web/src/components/EmailEditor/EmailEditor.tsx
index 36dd2cf2..f7b8af99 100644
--- a/apps/web/src/components/EmailEditor/EmailEditor.tsx
+++ b/apps/web/src/components/EmailEditor/EmailEditor.tsx
@@ -29,9 +29,10 @@ import {
SelectTrigger,
SelectValue,
} from '@plunk/ui';
-import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
+import {Code2, Eye, Monitor, RotateCcw, Smartphone, Tablet, Upload, X} from 'lucide-react';
import {network} from '../../lib/network';
import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles';
+import {getInitialEditorMode, getModeToggleDecision} from './modeGuards';
import 'tippy.js/dist/tippy.css';
interface EmailEditorProps {
@@ -53,14 +54,15 @@ const commonVariables = [
];
export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
- // Detect if initial value has custom HTML and start in appropriate mode
- const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
+ const initialMode = getInitialEditorMode(value);
const [mode, setMode] = useState<'visual' | 'html'>(initialMode);
const [htmlContent, setHtmlContent] = useState(value);
const [showVariableDialog, setShowVariableDialog] = useState(false);
const [showImageDialog, setShowImageDialog] = useState(false);
- const [showModeWarningDialog, setShowModeWarningDialog] = useState(false);
+ // WHY: snapshot stores the original HTML before a lossy visual-mode switch,
+ // so the user can revert if the conversion dropped markup
+ const [htmlSnapshot, setHtmlSnapshot] = useState(null);
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const [imageUrl, setImageUrl] = useState('');
const [imageFile, setImageFile] = useState(null);
@@ -151,44 +153,40 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, editor]);
- // Use the same pattern detection as initialization (no editor manipulation)
- const detectCustomHtml = (html: string): boolean => {
- return detectCustomHtmlPatterns(html);
- };
-
const handleModeToggle = () => {
- if (mode === 'visual') {
- // Switching to HTML mode
+ const decision = getModeToggleDecision({
+ currentMode: mode,
+ htmlContent,
+ });
+
+ if (decision.nextMode === 'html') {
const currentHtml = editor?.getHTML() || '';
setHtmlContent(currentHtml);
+ setHtmlSnapshot(null);
setMode('html');
- } else {
- // Switching to visual mode - check if custom HTML will be lost
- if (detectCustomHtml(htmlContent)) {
- setShowModeWarningDialog(true);
- } else {
- switchToVisualMode();
- }
+ return;
}
- };
- const switchToVisualMode = () => {
- // Only switch if we have an editor and html content
if (editor) {
+ if (decision.snapshot) {
+ setHtmlSnapshot(decision.snapshot);
+ }
editor.commands.setContent(htmlContent || '');
onChange(htmlContent);
setMode('visual');
}
- setShowModeWarningDialog(false);
};
- const stayInHtmlMode = () => {
- // Explicitly stay in HTML mode and just close the dialog
- setShowModeWarningDialog(false);
- // Ensure we're in HTML mode
- if (mode !== 'html') {
- setMode('html');
- }
+ const revertToSnapshot = () => {
+ if (!htmlSnapshot) return;
+ setHtmlContent(htmlSnapshot);
+ onChange(htmlSnapshot);
+ setHtmlSnapshot(null);
+ setMode('html');
+ };
+
+ const dismissSnapshot = () => {
+ setHtmlSnapshot(null);
};
const handleHtmlChange = (newHtml: string) => {
@@ -385,6 +383,22 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
onInsertImage={() => setShowImageDialog(true)}
canUploadImages={canUploadImages}
/>
+ {htmlSnapshot && (
+
+
+ Some custom HTML may not display correctly in the visual editor.
+
+
+
+
+
+
+ )}
@@ -685,47 +699,6 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
- {/* Mode switch warning dialog */}
-
-
{/* Image insertion dialog */}