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 = '
Hello
'; + + 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 = '
Hello
'; + + 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 */} - { - // Only allow closing (not opening) and ensure we stay in current mode - if (!open) { - setShowModeWarningDialog(false); - } - }} - > - - - Custom HTML Detected - -
-

- Your HTML contains custom formatting, styles, or elements that the visual editor doesn't support. - Switching to visual mode will cause these customizations to be lost or modified. -

-
-

This may affect:

-
    -
  • Custom HTML elements and attributes
  • -
  • Inline styles and CSS classes
  • -
  • Complex table structures
  • -
  • Custom formatting or layout
  • -
-
- -
- - -
-
-
-
- {/* Image insertion dialog */} diff --git a/apps/web/src/components/EmailEditor/modeGuards.ts b/apps/web/src/components/EmailEditor/modeGuards.ts new file mode 100644 index 00000000..ebaa7896 --- /dev/null +++ b/apps/web/src/components/EmailEditor/modeGuards.ts @@ -0,0 +1,29 @@ +import {detectCustomHtmlPatterns} from '../../lib/emailStyles'; + +export type EmailEditorMode = 'visual' | 'html'; + +export type ModeToggleDecision = {action: 'switch'; nextMode: EmailEditorMode; snapshot?: string}; + +export const getInitialEditorMode = (value: string): EmailEditorMode => { + return detectCustomHtmlPatterns(value) ? 'html' : 'visual'; +}; + +export const getModeToggleDecision = ({ + currentMode, + htmlContent, +}: { + currentMode: EmailEditorMode; + htmlContent: string; +}): ModeToggleDecision => { + if (currentMode === 'visual') { + return {action: 'switch', nextMode: 'html'}; + } + + // WHY: custom HTML detection has false positives, so we allow the switch + // but snapshot the original HTML so the user can revert without data loss + if (detectCustomHtmlPatterns(htmlContent)) { + return {action: 'switch', nextMode: 'visual', snapshot: htmlContent}; + } + + return {action: 'switch', nextMode: 'visual'}; +};