/* eslint-disable @typescript-eslint/no-explicit-any */ import {EditorContent, useEditor} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import {TextAlign} from '@tiptap/extension-text-align'; import {Color} from '@tiptap/extension-color'; import {TextStyle} from '@tiptap/extension-text-style'; import {Link} from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import {Variable} from './VariableExtension'; import {setAvailableVariables, VariableMention} from './VariableMention'; import {Toolbar} from './Toolbar'; import {ResizableImage} from './ResizableImage'; import {HtmlEditor} from './HtmlEditor'; import {useContactFields, useContacts} from '../../lib/hooks/useContacts'; import {useConfig} from '../../lib/hooks/useConfig'; import {useEffect, useRef, useState} from 'react'; import {renderTemplate} from '@plunk/shared'; import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@plunk/ui'; import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react'; import {network} from '../../lib/network'; import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles'; import 'tippy.js/dist/tippy.css'; interface EmailEditorProps { value: string; onChange: (value: string) => void; placeholder?: string; // Optional props for preview header subject?: string; from?: string; replyTo?: string; } const commonVariables = [ {name: 'id', description: 'Contact ID'}, {name: 'email', description: 'Recipient email address'}, {name: 'unsubscribeUrl', description: 'Unsubscribe link'}, {name: 'subscribeUrl', description: 'Subscribe link'}, {name: 'manageUrl', description: 'Manage link'}, ]; 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 [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); const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop'); const [imageUrl, setImageUrl] = useState(''); const [imageFile, setImageFile] = useState(null); const [customVariable, setCustomVariable] = useState(''); const [defaultValue, setDefaultValue] = useState(''); const [selectedContactId, setSelectedContactId] = useState(''); const [previewUpdateTrigger, setPreviewUpdateTrigger] = useState(0); const fileInputRef = useRef(null); // Fetch available contact fields using SWR const {fields: availableFields} = useContactFields(); // Fetch contacts for preview using SWR const {contacts} = useContacts({limit: 50}); // Update available variables when fields change useEffect(() => { if (availableFields.length > 0) { setAvailableVariables(availableFields); } }, [availableFields]); const {data: config} = useConfig(); const canUploadImages = Boolean(config?.features.storage.s3Enabled); const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, }), TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'], }), Color, TextStyle, Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer', }, }), ResizableImage, Variable, VariableMention, Placeholder.configure({ placeholder: placeholder || 'Your next email starts here!', }), ], // Only initialize with content if starting in visual mode // If starting in HTML mode (due to custom HTML), keep editor empty content: initialMode === 'visual' ? value || '' : '', editorProps: { attributes: { class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] px-4 py-3 text-neutral-900', }, }, onUpdate: ({editor}) => { const html = editor.getHTML(); onChange(html); setHtmlContent(html); // Trigger preview update setPreviewUpdateTrigger(prev => prev + 1); }, }); // Update editor content when value prop changes from outside useEffect(() => { if (editor && value !== editor.getHTML() && value !== htmlContent) { // Only update editor if in visual mode or if value is simple HTML const isCustomHtml = detectCustomHtmlPatterns(value); if (!isCustomHtml && mode === 'visual') { editor.commands.setContent(value || ''); } // Always update htmlContent to stay in sync setHtmlContent(value); // If custom HTML is detected and we're in visual mode, switch to HTML mode if (isCustomHtml && mode === 'visual') { setMode('html'); } } // 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 currentHtml = editor?.getHTML() || ''; setHtmlContent(currentHtml); setMode('html'); } else { // Switching to visual mode - check if custom HTML will be lost if (detectCustomHtml(htmlContent)) { setShowModeWarningDialog(true); } else { switchToVisualMode(); } } }; const switchToVisualMode = () => { // Only switch if we have an editor and html content if (editor) { 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 handleHtmlChange = (newHtml: string) => { setHtmlContent(newHtml); onChange(newHtml); }; const insertVariable = (varName: string, withDefault?: boolean) => { if (editor) { // Insert the variable placeholder as text with optional default value const variableText = withDefault && defaultValue ? `{{${varName} ?? ${defaultValue}}}` : `{{${varName}}}`; editor.chain().focus().insertContent(variableText).run(); setShowVariableDialog(false); setCustomVariable(''); setDefaultValue(''); } }; const handleImageUpload = async () => { if (!imageFile && !imageUrl) return; if (imageUrl) { // Insert external URL (editor?.chain().focus() as any).setImage({src: imageUrl}).run(); setImageUrl(''); setShowImageDialog(false); } else if (imageFile) { try { // Upload to S3/Minio const formData = new FormData(); formData.append('image', imageFile); const response = await network.upload<{url: string; key: string}>('POST', '/uploads/image', formData); // Insert the uploaded image URL (editor?.chain().focus() as any).setImage({src: response.url}).run(); setImageFile(null); setShowImageDialog(false); } catch (error) { console.error('Failed to upload image:', error); alert('Failed to upload image. Please try again.'); } } }; const replaceVariables = (text: string, contactData: Record) => { return renderTemplate(text, contactData); }; const getPreviewHtml = () => { const currentHtml = mode === 'visual' ? editor?.getHTML() || '' : htmlContent; if (!selectedContactId) return currentHtml; const contact = contacts.find(c => c.id === selectedContactId); if (!contact) return currentHtml; const contactData = { email: contact.email, unsubscribed: (contact as {subscribed?: boolean}).subscribed ? 'No' : 'Yes', unsubscribeUrl: `${window.location.origin}/unsubscribe/${contact.id}`, subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`, manageUrl: `${window.location.origin}/manage/${contact.id}`, data: contact.data || {}, ...((contact.data as Record | null) || {}), }; return replaceVariables(currentHtml, contactData); }; const getPreviewSubject = () => { if (!subject || !selectedContactId) return subject || ''; const contact = contacts.find(c => c.id === selectedContactId); if (!contact) return subject; const contactData = { email: contact.email, unsubscribed: (contact as {subscribed?: boolean}).subscribed ? 'No' : 'Yes', unsubscribeUrl: `${window.location.origin}/unsubscribe/${contact.id}`, subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`, manageUrl: `${window.location.origin}/manage/${contact.id}`, data: contact.data || {}, ...((contact.data as Record | null) || {}), }; return replaceVariables(subject, contactData); }; const getPreviewContainerWidth = () => { // Return the actual width the iframe should have (for media queries) switch (previewDevice) { case 'mobile': return '375px'; case 'tablet': return '768px'; case 'desktop': default: return '1200px'; // Standard desktop email width } }; // Ref for the preview iframe const previewIframeRef = useRef(null); // Update iframe content when preview changes useEffect(() => { if (previewIframeRef.current && selectedContactId) { const iframe = previewIframeRef.current; const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (iframeDoc) { const previewContent = getPreviewHtml(); const fullHtml = wrapEmailWithStyles(previewContent); iframeDoc.open(); iframeDoc.write(fullHtml); iframeDoc.close(); // Auto-adjust iframe height to content const adjustHeight = () => { if (iframe.contentWindow) { const height = iframe.contentWindow.document.body.scrollHeight; iframe.style.height = `${Math.max(400, height + 40)}px`; } }; // Adjust height after content loads if (iframe.contentWindow) { iframe.contentWindow.addEventListener('load', adjustHeight); // Also adjust immediately for already-loaded content setTimeout(adjustHeight, 100); setTimeout(adjustHeight, 300); // Fallback for slow-loading images } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedContactId, htmlContent, mode, previewDevice, previewUpdateTrigger]); return (
{/* Mode toggle */}
{/* Editor content */} {mode === 'visual' ? ( <> setShowVariableDialog(true)} onInsertImage={() => setShowImageDialog(true)} canUploadImages={canUploadImages} />
{selectedContactId && ( <>

Preview

({previewDevice === 'mobile' ? '375px' : previewDevice === 'tablet' ? '768px' : '1200px'})
{/* Email Header Preview */} {(subject || from || replyTo) && (
{subject && (

Subject

{getPreviewSubject()}

)} {(from || replyTo) && (
{from && (

From

{from}

)} {replyTo && (

Reply-To

{replyTo}

)}
)}
)}