feat: Complete markdown syntax refinement and variable protection
✅ New cleaner syntax implemented: - [card:type] instead of [card type='type'] - [button:style](url)Text[/button] instead of [button url='...' style='...'] - Standard markdown images:  ✅ Variable protection from markdown parsing: - Variables with underscores (e.g., {order_items_table}) now protected - HTML comment placeholders prevent italic/bold parsing - All variables render correctly in preview ✅ Button rendering fixes: - Buttons work in Visual mode inside cards - Buttons work in Preview mode - Button clicks prevented in visual editor - Proper styling for solid and outline buttons ✅ Backward compatibility: - Old syntax still supported - No breaking changes ✅ Bug fixes: - Fixed order_item_table → order_items_table naming - Fixed button regex to match across newlines - Added button/image parsing to parseMarkdownBasics - Prevented button clicks on .button and .button-outline classes 📚 Documentation: - NEW_MARKDOWN_SYNTAX.md - Complete user guide - MARKDOWN_SYNTAX_AND_VARIABLES.md - Technical analysis
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { EmailBlock } from './types';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { parseMarkdownBasics } from '@/lib/markdown-utils';
|
||||
|
||||
interface BlockRendererProps {
|
||||
block: EmailBlock;
|
||||
@@ -27,7 +28,16 @@ export function BlockRenderer({
|
||||
// Prevent navigation in builder
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'A' || target.tagName === 'BUTTON' || target.closest('a') || target.closest('button')) {
|
||||
if (
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.closest('button') ||
|
||||
target.classList.contains('button') ||
|
||||
target.classList.contains('button-outline') ||
|
||||
target.closest('.button') ||
|
||||
target.closest('.button-outline')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
@@ -73,17 +83,20 @@ export function BlockRenderer({
|
||||
}
|
||||
};
|
||||
|
||||
// Convert markdown to HTML for visual rendering
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
|
||||
return (
|
||||
<div style={cardStyles[block.cardType]}>
|
||||
<div
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2"
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
|
||||
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||
dangerouslySetInnerHTML={{ __html: block.content }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'button':
|
||||
case 'button': {
|
||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
@@ -92,7 +105,7 @@ export function BlockRenderer({
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
}
|
||||
: {
|
||||
display: 'inline-block',
|
||||
@@ -102,19 +115,57 @@ export function BlockRenderer({
|
||||
border: '2px solid #7f54b3',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align || 'center',
|
||||
};
|
||||
|
||||
if (block.widthMode === 'full') {
|
||||
buttonStyle.display = 'block';
|
||||
buttonStyle.width = '100%';
|
||||
buttonStyle.textAlign = 'center';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<a
|
||||
href={block.link}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
<a href={block.link} style={buttonStyle}>
|
||||
{block.text}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align,
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const imgStyle: React.CSSProperties = {
|
||||
display: 'inline-block',
|
||||
};
|
||||
|
||||
if (block.widthMode === 'full') {
|
||||
imgStyle.display = 'block';
|
||||
imgStyle.width = '100%';
|
||||
imgStyle.height = 'auto';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
imgStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
imgStyle.width = '100%';
|
||||
imgStyle.height = 'auto';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<img src={block.src} alt={block.alt || ''} style={imgStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return <hr className="border-t border-gray-300 my-4" />;
|
||||
@@ -154,8 +205,8 @@ export function BlockRenderer({
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
{/* Only show edit button for card and button blocks */}
|
||||
{(block.type === 'card' || block.type === 'button') && (
|
||||
{/* Only show edit button for card, button, and image blocks */}
|
||||
{(block.type === 'card' || block.type === 'button' || block.type === 'image') && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1 hover:bg-gray-100 rounded text-blue-600 text-xs"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import { EmailBlock, CardType, ButtonStyle } from './types';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { BlockRenderer } from './BlockRenderer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, Type, Square, MousePointer, Minus, Space } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Plus, Type, Square, MousePointer, Minus, Space, Monitor, Image as ImageIcon } from 'lucide-react';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { parseMarkdownBasics } from '@/lib/markdown-utils';
|
||||
import { htmlToMarkdown } from '@/lib/html-to-markdown';
|
||||
import type { EmailBlock, CardBlock, ButtonBlock, ImageBlock, SpacerBlock, CardType, ButtonStyle, ContentWidth, ContentAlign } from './types';
|
||||
|
||||
interface EmailBuilderProps {
|
||||
blocks: EmailBlock[];
|
||||
@@ -18,6 +22,7 @@ interface EmailBuilderProps {
|
||||
}
|
||||
|
||||
export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderProps) {
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingContent, setEditingContent] = useState('');
|
||||
@@ -25,6 +30,37 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
const [editingButtonText, setEditingButtonText] = useState('');
|
||||
const [editingButtonLink, setEditingButtonLink] = useState('');
|
||||
const [editingButtonStyle, setEditingButtonStyle] = useState<ButtonStyle>('solid');
|
||||
const [editingWidthMode, setEditingWidthMode] = useState<ContentWidth>('fit');
|
||||
const [editingCustomMaxWidth, setEditingCustomMaxWidth] = useState<number | undefined>(undefined);
|
||||
const [editingAlign, setEditingAlign] = useState<ContentAlign>('center');
|
||||
const [editingImageSrc, setEditingImageSrc] = useState('');
|
||||
|
||||
// WordPress Media Library integration
|
||||
const openMediaLibrary = (callback: (url: string) => void) => {
|
||||
// Check if wp.media is available
|
||||
if (typeof (window as any).wp === 'undefined' || typeof (window as any).wp.media === 'undefined') {
|
||||
console.error('WordPress media library is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = (window as any).wp.media({
|
||||
title: __('Select or Upload Image'),
|
||||
button: {
|
||||
text: __('Use this image'),
|
||||
},
|
||||
multiple: false,
|
||||
library: {
|
||||
type: 'image',
|
||||
},
|
||||
});
|
||||
|
||||
frame.on('select', () => {
|
||||
const attachment = frame.state().get('selection').first().toJSON();
|
||||
callback(attachment.url);
|
||||
});
|
||||
|
||||
frame.open();
|
||||
};
|
||||
|
||||
const addBlock = (type: EmailBlock['type']) => {
|
||||
const newBlock: EmailBlock = (() => {
|
||||
@@ -33,7 +69,9 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
case 'card':
|
||||
return { id, type, cardType: 'default', content: '<h2>Card Title</h2><p>Your content here...</p>' };
|
||||
case 'button':
|
||||
return { id, type, text: 'Click Here', link: '{order_url}', style: 'solid' };
|
||||
return { id, type, text: 'Click Here', link: '{order_url}', style: 'solid', widthMode: 'fit', align: 'center' };
|
||||
case 'image':
|
||||
return { id, type, src: 'https://via.placeholder.com/600x200', alt: 'Image', widthMode: 'fit', align: 'center' };
|
||||
case 'divider':
|
||||
return { id, type };
|
||||
case 'spacer':
|
||||
@@ -66,12 +104,22 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
setEditingBlockId(block.id);
|
||||
|
||||
if (block.type === 'card') {
|
||||
setEditingContent(block.content);
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
setEditingContent(htmlContent);
|
||||
setEditingCardType(block.cardType);
|
||||
} else if (block.type === 'button') {
|
||||
setEditingButtonText(block.text);
|
||||
setEditingButtonLink(block.link);
|
||||
setEditingButtonStyle(block.style);
|
||||
setEditingWidthMode(block.widthMode || 'fit');
|
||||
setEditingCustomMaxWidth(block.customMaxWidth);
|
||||
setEditingAlign(block.align || 'center');
|
||||
} else if (block.type === 'image') {
|
||||
setEditingImageSrc(block.src);
|
||||
setEditingWidthMode(block.widthMode);
|
||||
setEditingCustomMaxWidth(block.customMaxWidth);
|
||||
setEditingAlign(block.align);
|
||||
}
|
||||
|
||||
setEditDialogOpen(true);
|
||||
@@ -84,9 +132,27 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
if (block.id !== editingBlockId) return block;
|
||||
|
||||
if (block.type === 'card') {
|
||||
return { ...block, content: editingContent, cardType: editingCardType };
|
||||
// Convert HTML from rich text editor back to markdown for storage
|
||||
const markdownContent = htmlToMarkdown(editingContent);
|
||||
return { ...block, content: markdownContent, cardType: editingCardType };
|
||||
} else if (block.type === 'button') {
|
||||
return { ...block, text: editingButtonText, link: editingButtonLink, style: editingButtonStyle };
|
||||
return {
|
||||
...block,
|
||||
text: editingButtonText,
|
||||
link: editingButtonLink,
|
||||
style: editingButtonStyle,
|
||||
widthMode: editingWidthMode,
|
||||
customMaxWidth: editingCustomMaxWidth,
|
||||
align: editingAlign,
|
||||
};
|
||||
} else if (block.type === 'image') {
|
||||
return {
|
||||
...block,
|
||||
src: editingImageSrc,
|
||||
widthMode: editingWidthMode,
|
||||
customMaxWidth: editingCustomMaxWidth,
|
||||
align: editingAlign,
|
||||
};
|
||||
}
|
||||
|
||||
return block;
|
||||
@@ -99,6 +165,24 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
|
||||
const editingBlock = blocks.find(b => b.id === editingBlockId);
|
||||
|
||||
// Mobile fallback
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-muted/30 rounded-lg border-2 border-dashed border-muted-foreground/20 min-h-[400px] text-center">
|
||||
<Monitor className="w-16 h-16 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__('Desktop Only Feature')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-4">
|
||||
{__('The email builder requires a desktop screen for the best editing experience. Please switch to a desktop or tablet device to use this feature.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Minimum screen width: 768px')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Add Block Toolbar */}
|
||||
@@ -126,6 +210,15 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
<MousePointer className="h-3 w-3" />
|
||||
{__('Button')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('image')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
{__('Image')}
|
||||
</Button>
|
||||
<div className="border-l mx-1"></div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -151,7 +244,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
|
||||
{/* Email Canvas */}
|
||||
<div className="bg-gray-100 rounded-lg p-6 min-h-[400px]">
|
||||
<div className="max-w-2xl mx-auto bg-gray-50 rounded-lg shadow-sm p-8 space-y-6">
|
||||
<div className="max-w-2xl mx-auto rounded-lg shadow-sm p-8 space-y-6">
|
||||
{blocks.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>{__('No blocks yet. Add blocks using the toolbar above.')}</p>
|
||||
@@ -176,11 +269,36 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent
|
||||
className="sm:max-w-2xl"
|
||||
onInteractOutside={(e) => {
|
||||
// Check if WordPress media modal is currently open
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
|
||||
if (wpMediaOpen) {
|
||||
// If WP media is open, ALWAYS prevent dialog from closing
|
||||
// regardless of where the click happened
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// If WP media is not open, prevent closing dialog for outside clicks
|
||||
e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Allow escape to close WP media modal
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
if (wpMediaOpen) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingBlock?.type === 'card' && __('Edit Card')}
|
||||
{editingBlock?.type === 'button' && __('Edit Button')}
|
||||
{editingBlock?.type === 'image' && __('Edit Image')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Make changes to your block. You can use variables like {customer_name} or {order_number}.')}
|
||||
@@ -197,6 +315,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">{__('Basic (Plain Text)')}</SelectItem>
|
||||
<SelectItem value="default">{__('Default')}</SelectItem>
|
||||
<SelectItem value="success">{__('Success')}</SelectItem>
|
||||
<SelectItem value="info">{__('Info')}</SelectItem>
|
||||
@@ -264,6 +383,112 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Button Width')}</Label>
|
||||
<Select value={editingWidthMode} onValueChange={(value: ContentWidth) => setEditingWidthMode(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fit">{__('Fit content')}</SelectItem>
|
||||
<SelectItem value="full">{__('Full width')}</SelectItem>
|
||||
<SelectItem value="custom">{__('Custom max width')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editingWidthMode === 'custom' && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="button-max-width">{__('Max width (px)')}</Label>
|
||||
<Input
|
||||
id="button-max-width"
|
||||
type="number"
|
||||
value={editingCustomMaxWidth ?? ''}
|
||||
onChange={(e) => setEditingCustomMaxWidth(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Alignment')}</Label>
|
||||
<Select value={editingAlign} onValueChange={(value: ContentAlign) => setEditingAlign(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">{__('Left')}</SelectItem>
|
||||
<SelectItem value="center">{__('Center')}</SelectItem>
|
||||
<SelectItem value="right">{__('Right')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingBlock?.type === 'image' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image-src">{__('Image URL')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="image-src"
|
||||
value={editingImageSrc}
|
||||
onChange={(e) => setEditingImageSrc(e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openMediaLibrary(setEditingImageSrc)}
|
||||
title={__('Select from Media Library')}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Enter image URL or click the icon to select from WordPress media library')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Image Width')}</Label>
|
||||
<Select
|
||||
value={editingWidthMode}
|
||||
onValueChange={(value: ContentWidth) => setEditingWidthMode(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fit">{__('Fit content')}</SelectItem>
|
||||
<SelectItem value="full">{__('Full width')}</SelectItem>
|
||||
<SelectItem value="custom">{__('Custom max width')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editingWidthMode === 'custom' && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="image-max-width">{__('Max width (px)')}</Label>
|
||||
<Input
|
||||
id="image-max-width"
|
||||
type="number"
|
||||
value={editingCustomMaxWidth ?? ''}
|
||||
onChange={(e) => setEditingCustomMaxWidth(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Alignment')}</Label>
|
||||
<Select value={editingAlign} onValueChange={(value: ContentAlign) => setEditingAlign(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">{__('Left')}</SelectItem>
|
||||
<SelectItem value="center">{__('Center')}</SelectItem>
|
||||
<SelectItem value="right">{__('Right')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,87 @@
|
||||
import { EmailBlock } from './types';
|
||||
import { EmailBlock, CardBlock, ButtonBlock, ImageBlock, SpacerBlock, CardType, ButtonStyle, ContentWidth, ContentAlign } from './types';
|
||||
|
||||
/**
|
||||
* Convert HTML tags to markdown
|
||||
*/
|
||||
function convertHtmlToMarkdown(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||
|
||||
// Bold
|
||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
||||
|
||||
// Italic
|
||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Links
|
||||
markdown = markdown.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||
|
||||
// Paragraphs
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Line breaks
|
||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||
|
||||
// Lists
|
||||
markdown = markdown.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
||||
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
|
||||
});
|
||||
markdown = markdown.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
|
||||
let counter = 1;
|
||||
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, () => `${counter++}. $1\n`);
|
||||
});
|
||||
|
||||
// Clean up extra newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert blocks directly to clean markdown (no HTML pollution)
|
||||
*/
|
||||
export function blocksToMarkdown(blocks: EmailBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'card': {
|
||||
const cardBlock = block as CardBlock;
|
||||
// Use new [card:type] syntax
|
||||
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
||||
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
||||
}
|
||||
|
||||
case 'button': {
|
||||
const buttonBlock = block as ButtonBlock;
|
||||
// Use new [button:style](url)Text[/button] syntax
|
||||
const style = buttonBlock.style || 'solid';
|
||||
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
const imageBlock = block as ImageBlock;
|
||||
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return '---';
|
||||
|
||||
case 'spacer': {
|
||||
const spacerBlock = block as SpacerBlock;
|
||||
return `[spacer height="${spacerBlock.height}"]`;
|
||||
}
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert blocks to [card] syntax HTML
|
||||
@@ -12,9 +95,29 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
||||
}
|
||||
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
||||
|
||||
case 'button':
|
||||
case 'button': {
|
||||
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
||||
return `<p style="text-align: center;"><a href="${block.link}" class="${buttonClass}">${block.text}</a></p>`;
|
||||
const align = block.align || 'center';
|
||||
let linkStyle = '';
|
||||
if (block.widthMode === 'full') {
|
||||
linkStyle = 'display:block;width:100%;text-align:center;';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
linkStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;margin:0 auto;`;
|
||||
}
|
||||
const styleAttr = linkStyle ? ` style="${linkStyle}"` : '';
|
||||
return `<p style="text-align: ${align};"><a href="${block.link}" class="${buttonClass}"${styleAttr}>${block.text}</a></p>`;
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
let wrapperStyle = `text-align: ${block.align};`;
|
||||
let imgStyle = '';
|
||||
if (block.widthMode === 'full') {
|
||||
imgStyle = 'display:block;width:100%;height:auto;';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
imgStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;height:auto;margin:0 auto;`;
|
||||
}
|
||||
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
||||
@@ -29,14 +132,14 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [card] syntax HTML to blocks
|
||||
* Convert [card] syntax HTML or <div class="card"> HTML to blocks
|
||||
*/
|
||||
export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
|
||||
// Split by [card] tags and other elements
|
||||
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
||||
// Match both [card] syntax and <div class="card"> HTML
|
||||
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
@@ -63,33 +166,122 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
for (const part of parts) {
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check if it's a card
|
||||
const cardMatch = part.match(/\[card([^\]]*)\](.*?)\[\/card\]/s);
|
||||
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
||||
let content = '';
|
||||
let cardType = 'default';
|
||||
|
||||
// Try new [card:type] syntax first
|
||||
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1];
|
||||
const content = cardMatch[2].trim();
|
||||
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
||||
const cardType = (typeMatch ? typeMatch[1] : 'default') as any;
|
||||
|
||||
cardType = cardMatch[1];
|
||||
content = cardMatch[2].trim();
|
||||
} else {
|
||||
// Try old [card type="..."] syntax
|
||||
cardMatch = part.match(/\[card([^\]]*)\]([\s\S]*?)\[\/card\]/s);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1];
|
||||
content = cardMatch[2].trim();
|
||||
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
||||
cardType = (typeMatch ? typeMatch[1] : 'default');
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardMatch) {
|
||||
// <div class="card"> HTML syntax
|
||||
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
||||
if (htmlCardMatch) {
|
||||
cardType = (htmlCardMatch[1] || 'default');
|
||||
content = htmlCardMatch[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
// Convert HTML content to markdown for clean editing
|
||||
// But only if it actually contains HTML tags
|
||||
const hasHtmlTags = /<[^>]+>/.test(content);
|
||||
const markdownContent = hasHtmlTags ? convertHtmlToMarkdown(content) : content;
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content
|
||||
cardType: cardType as any,
|
||||
content: markdownContent
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a button
|
||||
// Check if it's a button - try new syntax first
|
||||
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
const style = buttonMatch[1] as ButtonStyle;
|
||||
const url = buttonMatch[2];
|
||||
const text = buttonMatch[3].trim();
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
link: url,
|
||||
text: text,
|
||||
style: style,
|
||||
align: 'center',
|
||||
widthMode: 'fit'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try old [button url="..."] syntax
|
||||
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
const url = buttonMatch[1];
|
||||
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
||||
const text = buttonMatch[3].trim();
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
link: url,
|
||||
text: text,
|
||||
style: style,
|
||||
align: 'center',
|
||||
widthMode: 'fit'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check HTML button syntax
|
||||
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/);
|
||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
||||
part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/);
|
||||
if (buttonMatch) {
|
||||
const hasStyle = buttonMatch.length === 5;
|
||||
const styleAttr = hasStyle ? buttonMatch[3] : '';
|
||||
const textIndex = hasStyle ? 4 : 3;
|
||||
const styleClassIndex = 2;
|
||||
|
||||
let widthMode: any = 'fit';
|
||||
let customMaxWidth: number | undefined = undefined;
|
||||
if (styleAttr.includes('width:100%') && !styleAttr.includes('max-width')) {
|
||||
widthMode = 'full';
|
||||
} else if (styleAttr.includes('max-width')) {
|
||||
widthMode = 'custom';
|
||||
const maxMatch = styleAttr.match(/max-width:(\d+)px/);
|
||||
if (maxMatch) {
|
||||
customMaxWidth = parseInt(maxMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract alignment from parent <p> tag if present
|
||||
const alignMatch = part.match(/text-align:\s*(left|center|right)/);
|
||||
const align = alignMatch ? alignMatch[1] as any : 'center';
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: buttonMatch[3],
|
||||
text: buttonMatch[textIndex],
|
||||
link: buttonMatch[1],
|
||||
style: buttonMatch[2].includes('outline') ? 'outline' : 'solid'
|
||||
style: buttonMatch[styleClassIndex].includes('outline') ? 'outline' : 'solid',
|
||||
widthMode,
|
||||
customMaxWidth,
|
||||
align,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -111,3 +303,110 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert clean markdown directly to blocks (no HTML intermediary)
|
||||
*/
|
||||
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
|
||||
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
||||
let remaining = markdown;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
remaining = remaining.trim();
|
||||
if (!remaining) break;
|
||||
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check for [card] blocks - match with proper boundaries
|
||||
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1].trim();
|
||||
const content = cardMatch[2].trim();
|
||||
|
||||
// Extract card type
|
||||
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
||||
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
||||
|
||||
// Extract background
|
||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||
const bg = bgMatch?.[1];
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content,
|
||||
bg,
|
||||
});
|
||||
|
||||
// Advance past this card
|
||||
remaining = remaining.substring(cardMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [button] blocks
|
||||
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: buttonMatch[3].trim(),
|
||||
link: buttonMatch[1],
|
||||
style: (buttonMatch[2] || 'solid') as ButtonStyle,
|
||||
align: 'center',
|
||||
widthMode: 'fit',
|
||||
});
|
||||
|
||||
remaining = remaining.substring(buttonMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [image] blocks
|
||||
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
||||
if (imageMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'image',
|
||||
src: imageMatch[1],
|
||||
alt: imageMatch[2] || '',
|
||||
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
||||
align: (imageMatch[4] || 'center') as ContentAlign,
|
||||
});
|
||||
|
||||
remaining = remaining.substring(imageMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [spacer] blocks
|
||||
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
||||
if (spacerMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'spacer',
|
||||
height: parseInt(spacerMatch[1]),
|
||||
});
|
||||
|
||||
remaining = remaining.substring(spacerMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for horizontal rule
|
||||
if (remaining.startsWith('---')) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
remaining = remaining.substring(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If nothing matches, skip this character to avoid infinite loop
|
||||
remaining = remaining.substring(1);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { EmailBuilder } from './EmailBuilder';
|
||||
export { BlockRenderer } from './BlockRenderer';
|
||||
export { blocksToHTML, htmlToBlocks } from './converter';
|
||||
export { blocksToHTML, htmlToBlocks, blocksToMarkdown, markdownToBlocks } from './converter';
|
||||
export * from './types';
|
||||
|
||||
73
admin-spa/src/components/EmailBuilder/markdown-converter.ts
Normal file
73
admin-spa/src/components/EmailBuilder/markdown-converter.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EmailBlock, CardType, ButtonStyle } from './types';
|
||||
|
||||
/**
|
||||
* Convert markdown to blocks - respects [card]...[/card] boundaries
|
||||
*/
|
||||
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < markdown.length) {
|
||||
// Skip whitespace
|
||||
while (pos < markdown.length && /\s/.test(markdown[pos])) pos++;
|
||||
if (pos >= markdown.length) break;
|
||||
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check for [card]
|
||||
if (markdown.substr(pos, 5) === '[card') {
|
||||
const cardStart = pos;
|
||||
const cardOpenEnd = markdown.indexOf(']', pos);
|
||||
const cardClose = markdown.indexOf('[/card]', pos);
|
||||
|
||||
if (cardOpenEnd !== -1 && cardClose !== -1) {
|
||||
const attributes = markdown.substring(pos + 5, cardOpenEnd);
|
||||
const content = markdown.substring(cardOpenEnd + 1, cardClose).trim();
|
||||
|
||||
// Parse type
|
||||
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
||||
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content,
|
||||
});
|
||||
|
||||
pos = cardClose + 7; // Skip [/card]
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for [button]
|
||||
if (markdown.substr(pos, 7) === '[button') {
|
||||
const buttonEnd = markdown.indexOf('[/button]', pos);
|
||||
if (buttonEnd !== -1) {
|
||||
const fullButton = markdown.substring(pos, buttonEnd + 9);
|
||||
const match = fullButton.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||
|
||||
if (match) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: match[3].trim(),
|
||||
link: match[1],
|
||||
style: (match[2] || 'solid') as ButtonStyle,
|
||||
align: 'center',
|
||||
widthMode: 'fit',
|
||||
});
|
||||
|
||||
pos = buttonEnd + 9;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip unknown content
|
||||
pos++;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
export type BlockType = 'card' | 'button' | 'divider' | 'spacer';
|
||||
export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image';
|
||||
|
||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero';
|
||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
|
||||
|
||||
export type ButtonStyle = 'solid' | 'outline';
|
||||
|
||||
export type ContentWidth = 'fit' | 'full' | 'custom';
|
||||
|
||||
export type ContentAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface BaseBlock {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
@@ -21,6 +25,18 @@ export interface ButtonBlock extends BaseBlock {
|
||||
text: string;
|
||||
link: string;
|
||||
style: ButtonStyle;
|
||||
widthMode?: ContentWidth;
|
||||
customMaxWidth?: number;
|
||||
align?: ContentAlign;
|
||||
}
|
||||
|
||||
export interface ImageBlock extends BaseBlock {
|
||||
type: 'image';
|
||||
src: string;
|
||||
alt?: string;
|
||||
widthMode: ContentWidth;
|
||||
customMaxWidth?: number;
|
||||
align: ContentAlign;
|
||||
}
|
||||
|
||||
export interface DividerBlock extends BaseBlock {
|
||||
@@ -32,7 +48,12 @@ export interface SpacerBlock extends BaseBlock {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type EmailBlock = CardBlock | ButtonBlock | DividerBlock | SpacerBlock;
|
||||
export type EmailBlock =
|
||||
| CardBlock
|
||||
| ButtonBlock
|
||||
| DividerBlock
|
||||
| SpacerBlock
|
||||
| ImageBlock;
|
||||
|
||||
export interface EmailTemplate {
|
||||
blocks: EmailBlock[];
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { Button } from './button';
|
||||
import { parseMarkdownToEmail, parseEmailToMarkdown } from '@/lib/markdown-parser';
|
||||
import { MarkdownToolbar } from './markdown-toolbar';
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
supportMarkdown?: boolean;
|
||||
supportMarkdown?: boolean; // Keep for backward compatibility but always use markdown
|
||||
}
|
||||
|
||||
export function CodeEditor({ value, onChange, placeholder, supportMarkdown = false }: CodeEditorProps) {
|
||||
const [mode, setMode] = useState<'html' | 'markdown'>('html');
|
||||
export function CodeEditor({ value, onChange, placeholder }: CodeEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// Handle markdown insertions from toolbar
|
||||
const handleInsert = (before: string, after: string = '') => {
|
||||
if (!viewRef.current) return;
|
||||
|
||||
const view = viewRef.current;
|
||||
const selection = view.state.selection.main;
|
||||
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
||||
|
||||
// Insert the markdown syntax
|
||||
const newText = before + selectedText + after;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: newText },
|
||||
selection: { anchor: selection.from + before.length + selectedText.length }
|
||||
});
|
||||
|
||||
// Focus back to editor
|
||||
view.focus();
|
||||
};
|
||||
|
||||
// Initialize editor once
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const view = new EditorView({
|
||||
doc: mode === 'markdown' ? parseEmailToMarkdown(value) : value,
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
mode === 'markdown' ? markdown() : html(),
|
||||
markdown(),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString();
|
||||
onChange(mode === 'markdown' ? parseMarkdownToEmail(content) : content);
|
||||
onChange(content);
|
||||
}
|
||||
}),
|
||||
],
|
||||
@@ -42,52 +59,33 @@ export function CodeEditor({ value, onChange, placeholder, supportMarkdown = fal
|
||||
return () => {
|
||||
view.destroy();
|
||||
};
|
||||
}, [mode]);
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Update editor when value prop changes
|
||||
// Update editor when value prop changes from external source
|
||||
useEffect(() => {
|
||||
if (viewRef.current) {
|
||||
const displayValue = mode === 'markdown' ? parseEmailToMarkdown(value) : value;
|
||||
if (displayValue !== viewRef.current.state.doc.toString()) {
|
||||
viewRef.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: displayValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
|
||||
viewRef.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [value, mode]);
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode(mode === 'html' ? 'markdown' : 'html');
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{supportMarkdown && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleMode}
|
||||
className="text-xs"
|
||||
>
|
||||
{mode === 'html' ? '📝 Switch to Markdown' : '🔧 Switch to HTML'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="border rounded-md overflow-hidden min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
{supportMarkdown && mode === 'markdown' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 Markdown syntax: Use <code>:::</code> for cards, <code>[button](url){text}</code> for buttons
|
||||
</p>
|
||||
)}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<MarkdownToolbar onInsert={handleInsert} />
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 Use the toolbar above or type markdown directly: **bold**, ## headings, [card]...[/card], [button]...[/button]
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
232
admin-spa/src/components/ui/markdown-toolbar.tsx
Normal file
232
admin-spa/src/components/ui/markdown-toolbar.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './button';
|
||||
import { Bold, Italic, Heading1, Heading2, Link, List, ListOrdered, Quote, Code, Square, Plus, Image, MousePointer } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './select';
|
||||
|
||||
interface MarkdownToolbarProps {
|
||||
onInsert: (before: string, after?: string) => void;
|
||||
}
|
||||
|
||||
export function MarkdownToolbar({ onInsert }: MarkdownToolbarProps) {
|
||||
const [showCardDialog, setShowCardDialog] = useState(false);
|
||||
const [selectedCardType, setSelectedCardType] = useState('default');
|
||||
const [showButtonDialog, setShowButtonDialog] = useState(false);
|
||||
const [buttonStyle, setButtonStyle] = useState('solid');
|
||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||
|
||||
const tools = [
|
||||
{ icon: Bold, label: 'Bold', before: '**', after: '**' },
|
||||
{ icon: Italic, label: 'Italic', before: '*', after: '*' },
|
||||
{ icon: Heading1, label: 'Heading 1', before: '# ', after: '' },
|
||||
{ icon: Heading2, label: 'Heading 2', before: '## ', after: '' },
|
||||
{ icon: Link, label: 'Link', before: '[', after: '](url)' },
|
||||
{ icon: List, label: 'Bullet List', before: '- ', after: '' },
|
||||
{ icon: ListOrdered, label: 'Numbered List', before: '1. ', after: '' },
|
||||
{ icon: Quote, label: 'Quote', before: '> ', after: '' },
|
||||
{ icon: Code, label: 'Code', before: '`', after: '`' },
|
||||
];
|
||||
|
||||
const cardTypes = [
|
||||
{ value: 'default', label: 'Default', description: 'Standard white card' },
|
||||
{ value: 'hero', label: 'Hero', description: 'Large header card with gradient' },
|
||||
{ value: 'success', label: 'Success', description: 'Green success message' },
|
||||
{ value: 'warning', label: 'Warning', description: 'Yellow warning message' },
|
||||
{ value: 'info', label: 'Info', description: 'Blue information card' },
|
||||
{ value: 'basic', label: 'Basic', description: 'Minimal styling' },
|
||||
];
|
||||
|
||||
const handleInsertCard = () => {
|
||||
const cardTemplate = selectedCardType === 'default'
|
||||
? '[card]\n\n## Your heading here\n\nYour content here...\n\n[/card]'
|
||||
: `[card:${selectedCardType}]\n\n## Your heading here\n\nYour content here...\n\n[/card]`;
|
||||
|
||||
onInsert(cardTemplate, '');
|
||||
setShowCardDialog(false);
|
||||
};
|
||||
|
||||
const handleInsertButton = () => {
|
||||
const buttonTemplate = `[button:${buttonStyle}](https://example.com)Click me[/button]`;
|
||||
onInsert(buttonTemplate, '');
|
||||
setShowButtonDialog(false);
|
||||
};
|
||||
|
||||
const handleInsertImage = () => {
|
||||
const imageTemplate = ``;
|
||||
onInsert(imageTemplate, '');
|
||||
setShowImageDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b bg-muted/30">
|
||||
{/* Card Insert Button with Dialog */}
|
||||
<Dialog open={showCardDialog} onOpenChange={setShowCardDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
title="Insert Card"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Card</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a card type to insert into your template
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Card Type</label>
|
||||
<Select value={selectedCardType} onValueChange={setSelectedCardType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cardTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div>
|
||||
<div className="font-medium">{type.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{type.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleInsertCard} className="w-full">
|
||||
Insert Card
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Button Insert Dialog */}
|
||||
<Dialog open={showButtonDialog} onOpenChange={setShowButtonDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
title="Insert Button"
|
||||
>
|
||||
<MousePointer className="h-4 w-4" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Button</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a button style to insert
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Style</label>
|
||||
<Select value={buttonStyle} onValueChange={setButtonStyle}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">
|
||||
<div>
|
||||
<div className="font-medium">Solid</div>
|
||||
<div className="text-xs text-muted-foreground">Filled background</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="outline">
|
||||
<div>
|
||||
<div className="font-medium">Outline</div>
|
||||
<div className="text-xs text-muted-foreground">Border only</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleInsertButton} className="w-full">
|
||||
Insert Button
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Image Insert Dialog */}
|
||||
<Dialog open={showImageDialog} onOpenChange={setShowImageDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
title="Insert Image"
|
||||
>
|
||||
<Image className="h-4 w-4" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Image</DialogTitle>
|
||||
<DialogDescription>
|
||||
Insert an image using standard Markdown syntax
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Syntax: <code className="px-1 py-0.5 bg-muted rounded"></code></p>
|
||||
<p className="mt-2">Example: <code className="px-1 py-0.5 bg-muted rounded"></code></p>
|
||||
</div>
|
||||
<Button onClick={handleInsertImage} className="w-full">
|
||||
Insert Image Template
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-8 bg-border" />
|
||||
|
||||
{/* Other formatting tools */}
|
||||
{tools.map((tool) => (
|
||||
<Button
|
||||
key={tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onInsert(tool.before, tool.after)}
|
||||
className="h-8 w-8 p-0"
|
||||
title={tool.label}
|
||||
>
|
||||
<tool.icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="hidden sm:inline">Quick formatting:</span>
|
||||
<code className="px-1 py-0.5 bg-muted rounded">**bold**</code>
|
||||
<code className="px-1 py-0.5 bg-muted rounded">## heading</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user