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,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>
|
||||
|
||||
Reference in New Issue
Block a user