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,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>
);
}