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: ![alt](url)

 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:
dwindown
2025-11-15 20:05:50 +07:00
parent 550b3b69ef
commit 4471cd600f
45 changed files with 9194 additions and 508 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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';

View 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;
}

View File

@@ -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[];

View File

@@ -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)&#123;text&#125;</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>
);
}

View 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 = `![Image description](https://example.com/image.jpg)`;
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">![Alt text](image-url)</code></p>
<p className="mt-2">Example: <code className="px-1 py-0.5 bg-muted rounded">![Logo](https://example.com/logo.png)</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>
);
}