feat: Code Mode Button Position & Markdown Support! 📝
## ✅ 3. Code Mode Button Moved to Left **Problem:** Inconsistent layout, tabs on right should be Editor/Preview only **Solution:** - Moved Code Mode button next to "Message Body" label - Editor/Preview tabs stay on the right - Consistent, logical layout **Before:** ``` Message Body [Editor|Preview] [Code Mode] ``` **After:** ``` Message Body [Code Mode] [Editor|Preview] ``` ## ✅ 4. Markdown Support in Code Mode! 🎉 **Problem:** HTML is verbose, not user-friendly for tech-savvy users **Solution:** - Added Markdown parser with ::: syntax for cards - Toggle between HTML and Markdown modes - Full bidirectional conversion **Markdown Syntax:** ```markdown :::card # Heading Your content here ::: :::card[success] ✅ Success message ::: [button](https://example.com){Click Here} [button style="outline"](url){Secondary Button} ``` **Features:** - Standard Markdown: headings, bold, italic, lists, links - Card blocks: :::card or :::card[type] - Button blocks: [button](url){text} - Variables: {order_url}, {customer_name} - Bidirectional conversion (HTML ↔ Markdown) **Files:** - `lib/markdown-parser.ts` - Parser implementation - `components/ui/code-editor.tsx` - Mode toggle - `routes/Settings/Notifications/EditTemplate.tsx` - Enable support - `DEPENDENCIES.md` - Add @codemirror/lang-markdown **Note:** Requires `npm install @codemirror/lang-markdown` Ready for remaining improvements (5-6)!
This commit is contained in:
@@ -6,7 +6,7 @@ Install all dependencies at once:
|
||||
|
||||
```bash
|
||||
cd admin-spa
|
||||
npm install @tiptap/extension-text-align @tiptap/extension-image codemirror @codemirror/lang-html @codemirror/theme-one-dark @radix-ui/react-radio-group
|
||||
npm install @tiptap/extension-text-align @tiptap/extension-image codemirror @codemirror/lang-html @codemirror/lang-markdown @codemirror/theme-one-dark @radix-ui/react-radio-group
|
||||
```
|
||||
|
||||
---
|
||||
@@ -24,12 +24,13 @@ npm install @tiptap/extension-text-align @tiptap/extension-image
|
||||
|
||||
### CodeMirror (for Code Mode)
|
||||
```bash
|
||||
npm install codemirror @codemirror/lang-html @codemirror/theme-one-dark
|
||||
npm install codemirror @codemirror/lang-html @codemirror/lang-markdown @codemirror/theme-one-dark
|
||||
```
|
||||
|
||||
**What they do:**
|
||||
- **codemirror**: Core editor with professional features
|
||||
- **@codemirror/lang-html**: HTML syntax highlighting & auto-completion
|
||||
- **@codemirror/lang-markdown**: Markdown syntax highlighting & auto-completion
|
||||
- **@codemirror/theme-one-dark**: Professional dark theme
|
||||
|
||||
### Radix UI (for UI Components)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } 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';
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
supportMarkdown?: boolean;
|
||||
}
|
||||
|
||||
export function CodeEditor({ value, onChange, placeholder }: CodeEditorProps) {
|
||||
export function CodeEditor({ value, onChange, placeholder, supportMarkdown = false }: CodeEditorProps) {
|
||||
const [mode, setMode] = useState<'html' | 'markdown'>('html');
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
@@ -17,14 +22,15 @@ export function CodeEditor({ value, onChange, placeholder }: CodeEditorProps) {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const view = new EditorView({
|
||||
doc: value,
|
||||
doc: mode === 'markdown' ? parseEmailToMarkdown(value) : value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
html(),
|
||||
mode === 'markdown' ? markdown() : html(),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChange(update.state.doc.toString());
|
||||
const content = update.state.doc.toString();
|
||||
onChange(mode === 'markdown' ? parseMarkdownToEmail(content) : content);
|
||||
}
|
||||
}),
|
||||
],
|
||||
@@ -36,25 +42,52 @@ export function CodeEditor({ value, onChange, placeholder }: CodeEditorProps) {
|
||||
return () => {
|
||||
view.destroy();
|
||||
};
|
||||
}, []);
|
||||
}, [mode]);
|
||||
|
||||
// Update editor when value prop changes
|
||||
useEffect(() => {
|
||||
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
|
||||
viewRef.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
}, [value, mode]);
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode(mode === 'html' ? 'markdown' : 'html');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="border rounded-md overflow-hidden min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
134
admin-spa/src/lib/markdown-parser.ts
Normal file
134
admin-spa/src/lib/markdown-parser.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Markdown to Email HTML Parser
|
||||
*
|
||||
* Supports:
|
||||
* - Standard Markdown (headings, bold, italic, lists, links)
|
||||
* - Card blocks with ::: syntax
|
||||
* - Button blocks with [button] syntax
|
||||
* - Variables with {variable_name}
|
||||
*/
|
||||
|
||||
export function parseMarkdownToEmail(markdown: string): string {
|
||||
let html = markdown;
|
||||
|
||||
// Parse card blocks first (:::card or :::card[type])
|
||||
html = html.replace(/:::card(?:\[(\w+)\])?\n([\s\S]*?):::/g, (match, type, content) => {
|
||||
const cardType = type || 'default';
|
||||
const parsedContent = parseMarkdownBasics(content.trim());
|
||||
return `[card${type ? ` type="${cardType}"` : ''}]\n${parsedContent}\n[/card]`;
|
||||
});
|
||||
|
||||
// Parse button blocks [button](url) or [button style="outline"](url)
|
||||
html = html.replace(/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/g, (match, style, url, text) => {
|
||||
return `[button link="${url}"${style ? ` style="${style}"` : ' style="solid"'}]${text}[/button]`;
|
||||
});
|
||||
|
||||
// Parse remaining markdown (outside cards)
|
||||
html = parseMarkdownBasics(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseMarkdownBasics(text: string): string {
|
||||
let html = text;
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links (but not button syntax)
|
||||
html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/^- (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
// Ordered lists
|
||||
html = html.replace(/^\d+\. (.*$)/gim, '<li>$1</li>');
|
||||
|
||||
// Paragraphs (lines not already in tags)
|
||||
const lines = html.split('\n');
|
||||
const processedLines = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.startsWith('<') || trimmed.startsWith('[')) return line;
|
||||
return `<p>${line}</p>`;
|
||||
});
|
||||
html = processedLines.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert email HTML back to Markdown
|
||||
*/
|
||||
export function parseEmailToMarkdown(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Convert [card] blocks to ::: syntax
|
||||
markdown = markdown.replace(/\[card(?:\s+type="(\w+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
||||
const mdContent = parseHtmlToMarkdownBasics(content.trim());
|
||||
return type ? `:::card[${type}]\n${mdContent}\n:::` : `:::card\n${mdContent}\n:::`;
|
||||
});
|
||||
|
||||
// Convert [button] blocks to markdown syntax
|
||||
markdown = markdown.replace(/\[button link="([^"]+)"(?:\s+style="(solid|outline)")?\]([^[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
return style && style !== 'solid'
|
||||
? `[button style="${style}"](${url}){${text.trim()}}`
|
||||
: `[button](${url}){${text.trim()}}`;
|
||||
});
|
||||
|
||||
// Convert remaining HTML to markdown
|
||||
markdown = parseHtmlToMarkdownBasics(markdown);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
function parseHtmlToMarkdownBasics(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1');
|
||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1');
|
||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1');
|
||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1');
|
||||
|
||||
// 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)');
|
||||
|
||||
// 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`);
|
||||
});
|
||||
|
||||
// Paragraphs
|
||||
markdown = markdown.replace(/<p>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Clean up extra newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
@@ -340,20 +340,8 @@ export default function EditTemplate() {
|
||||
<div className="space-y-4">
|
||||
{/* Tabs for Editor/Preview */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="editor" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Editor')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Label>{__('Message Body')}</Label>
|
||||
{activeTab === 'editor' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -365,6 +353,18 @@ export default function EditTemplate() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="editor" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Editor')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{activeTab === 'editor' && codeMode ? (
|
||||
@@ -373,9 +373,10 @@ export default function EditTemplate() {
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder={__('Enter HTML code with [card] tags...')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Edit raw HTML code with [card] syntax. Syntax highlighting and auto-completion enabled.')}
|
||||
{__('Edit raw HTML code with [card] syntax, or switch to Markdown mode for easier editing.')}
|
||||
</p>
|
||||
</div>
|
||||
) : activeTab === 'editor' ? (
|
||||
|
||||
Reference in New Issue
Block a user