From 1211430011cdef96d901dd55a959c0067f534039 Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 13 Nov 2025 11:50:38 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Code=20Mode=20Button=20Position=20&=20M?= =?UTF-8?q?arkdown=20Support!=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ 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)! --- admin-spa/DEPENDENCIES.md | 5 +- admin-spa/src/components/ui/code-editor.tsx | 71 +++++++--- admin-spa/src/lib/markdown-parser.ts | 134 ++++++++++++++++++ .../Settings/Notifications/EditTemplate.tsx | 29 ++-- 4 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 admin-spa/src/lib/markdown-parser.ts diff --git a/admin-spa/DEPENDENCIES.md b/admin-spa/DEPENDENCIES.md index 8f0341c..b22ad8a 100644 --- a/admin-spa/DEPENDENCIES.md +++ b/admin-spa/DEPENDENCIES.md @@ -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) diff --git a/admin-spa/src/components/ui/code-editor.tsx b/admin-spa/src/components/ui/code-editor.tsx index bb54c07..65efe30 100644 --- a/admin-spa/src/components/ui/code-editor.tsx +++ b/admin-spa/src/components/ui/code-editor.tsx @@ -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(null); const viewRef = useRef(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 ( -
+
+ {supportMarkdown && ( +
+ +
+ )} +
+ {supportMarkdown && mode === 'markdown' && ( +

+ 💡 Markdown syntax: Use ::: for cards, [button](url){text} for buttons +

+ )} +
); } diff --git a/admin-spa/src/lib/markdown-parser.ts b/admin-spa/src/lib/markdown-parser.ts new file mode 100644 index 0000000..7033599 --- /dev/null +++ b/admin-spa/src/lib/markdown-parser.ts @@ -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, '

$1

'); + html = html.replace(/^### (.*$)/gim, '

$1

'); + html = html.replace(/^## (.*$)/gim, '

$1

'); + html = html.replace(/^# (.*$)/gim, '

$1

'); + + // Bold + html = html.replace(/\*\*(.*?)\*\*/g, '$1'); + html = html.replace(/__(.*?)__/g, '$1'); + + // Italic + html = html.replace(/\*(.*?)\*/g, '$1'); + html = html.replace(/_(.*?)_/g, '$1'); + + // Links (but not button syntax) + html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Unordered lists + html = html.replace(/^\* (.*$)/gim, '
  • $1
  • '); + html = html.replace(/^- (.*$)/gim, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>)/s, '
      $1
    '); + + // Ordered lists + html = html.replace(/^\d+\. (.*$)/gim, '
  • $1
  • '); + + // 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 `

    ${line}

    `; + }); + 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>/gi, '# $1'); + markdown = markdown.replace(/

    (.*?)<\/h2>/gi, '## $1'); + markdown = markdown.replace(/

    (.*?)<\/h3>/gi, '### $1'); + markdown = markdown.replace(/

    (.*?)<\/h4>/gi, '#### $1'); + + // Bold + markdown = markdown.replace(/(.*?)<\/strong>/gi, '**$1**'); + markdown = markdown.replace(/(.*?)<\/b>/gi, '**$1**'); + + // Italic + markdown = markdown.replace(/(.*?)<\/em>/gi, '*$1*'); + markdown = markdown.replace(/(.*?)<\/i>/gi, '*$1*'); + + // Links + markdown = markdown.replace(/(.*?)<\/a>/gi, '[$2]($1)'); + + // Lists + markdown = markdown.replace(/
      ([\s\S]*?)<\/ul>/gi, (match, content) => { + return content.replace(/
    • (.*?)<\/li>/gi, '- $1\n'); + }); + markdown = markdown.replace(/
        ([\s\S]*?)<\/ol>/gi, (match, content) => { + let counter = 1; + return content.replace(/
      1. (.*?)<\/li>/gi, () => `${counter++}. $1\n`); + }); + + // Paragraphs + markdown = markdown.replace(/

        (.*?)<\/p>/gi, '$1\n\n'); + + // Clean up extra newlines + markdown = markdown.replace(/\n{3,}/g, '\n\n'); + + return markdown.trim(); +} diff --git a/admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx b/admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx index ae3f69d..c5bb65b 100644 --- a/admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx +++ b/admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx @@ -340,20 +340,8 @@ export default function EditTemplate() {

        {/* Tabs for Editor/Preview */}
        -
        - - - - - {__('Editor')} - - - - {__('Preview')} - - - + {activeTab === 'editor' && (
        + + + + + {__('Editor')} + + + + {__('Preview')} + + +
        {activeTab === 'editor' && codeMode ? ( @@ -373,9 +373,10 @@ export default function EditTemplate() { value={body} onChange={setBody} placeholder={__('Enter HTML code with [card] tags...')} + supportMarkdown={true} />

        - {__('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.')}

        ) : activeTab === 'editor' ? (