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:
dwindown
2025-11-13 11:50:38 +07:00
parent 4875c4af9d
commit 1211430011
4 changed files with 204 additions and 35 deletions

View File

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

View File

@@ -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)&#123;text&#125;</code> for buttons
</p>
)}
</div>
);
}

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

View File

@@ -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' ? (