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
|
```bash
|
||||||
cd admin-spa
|
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)
|
### CodeMirror (for Code Mode)
|
||||||
```bash
|
```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:**
|
**What they do:**
|
||||||
- **codemirror**: Core editor with professional features
|
- **codemirror**: Core editor with professional features
|
||||||
- **@codemirror/lang-html**: HTML syntax highlighting & auto-completion
|
- **@codemirror/lang-html**: HTML syntax highlighting & auto-completion
|
||||||
|
- **@codemirror/lang-markdown**: Markdown syntax highlighting & auto-completion
|
||||||
- **@codemirror/theme-one-dark**: Professional dark theme
|
- **@codemirror/theme-one-dark**: Professional dark theme
|
||||||
|
|
||||||
### Radix UI (for UI Components)
|
### 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 { EditorView, basicSetup } from 'codemirror';
|
||||||
import { html } from '@codemirror/lang-html';
|
import { html } from '@codemirror/lang-html';
|
||||||
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { Button } from './button';
|
||||||
|
import { parseMarkdownToEmail, parseEmailToMarkdown } from '@/lib/markdown-parser';
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
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 editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
@@ -17,14 +22,15 @@ export function CodeEditor({ value, onChange, placeholder }: CodeEditorProps) {
|
|||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
const view = new EditorView({
|
const view = new EditorView({
|
||||||
doc: value,
|
doc: mode === 'markdown' ? parseEmailToMarkdown(value) : value,
|
||||||
extensions: [
|
extensions: [
|
||||||
basicSetup,
|
basicSetup,
|
||||||
html(),
|
mode === 'markdown' ? markdown() : html(),
|
||||||
oneDark,
|
oneDark,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
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 () => {
|
return () => {
|
||||||
view.destroy();
|
view.destroy();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [mode]);
|
||||||
|
|
||||||
// Update editor when value prop changes
|
// Update editor when value prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
|
if (viewRef.current) {
|
||||||
|
const displayValue = mode === 'markdown' ? parseEmailToMarkdown(value) : value;
|
||||||
|
if (displayValue !== viewRef.current.state.doc.toString()) {
|
||||||
viewRef.current.dispatch({
|
viewRef.current.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: 0,
|
from: 0,
|
||||||
to: viewRef.current.state.doc.length,
|
to: viewRef.current.state.doc.length,
|
||||||
insert: value,
|
insert: displayValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [value]);
|
}
|
||||||
|
}, [value, mode]);
|
||||||
|
|
||||||
|
const toggleMode = () => {
|
||||||
|
setMode(mode === 'html' ? 'markdown' : 'html');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className="border rounded-md overflow-hidden min-h-[400px] font-mono text-sm"
|
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,8 +340,19 @@ export default function EditTemplate() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tabs for Editor/Preview */}
|
{/* Tabs for Editor/Preview */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>{__('Message Body')}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Label>{__('Message Body')}</Label>
|
||||||
|
{activeTab === 'editor' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCodeModeToggle}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
{codeMode ? __('Visual Builder') : __('Code Mode')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||||
<TabsList className="grid grid-cols-2">
|
<TabsList className="grid grid-cols-2">
|
||||||
<TabsTrigger value="editor" className="flex items-center gap-1 text-xs">
|
<TabsTrigger value="editor" className="flex items-center gap-1 text-xs">
|
||||||
@@ -354,17 +365,6 @@ export default function EditTemplate() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{activeTab === 'editor' && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCodeModeToggle}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
{codeMode ? __('Visual Builder') : __('Code Mode')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'editor' && codeMode ? (
|
{activeTab === 'editor' && codeMode ? (
|
||||||
@@ -373,9 +373,10 @@ export default function EditTemplate() {
|
|||||||
value={body}
|
value={body}
|
||||||
onChange={setBody}
|
onChange={setBody}
|
||||||
placeholder={__('Enter HTML code with [card] tags...')}
|
placeholder={__('Enter HTML code with [card] tags...')}
|
||||||
|
supportMarkdown={true}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'editor' ? (
|
) : activeTab === 'editor' ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user