ROOT CAUSE: Complete flow trace revealed syntax mismatch:
- blocksToMarkdown outputs NEW syntax: [card:type], [button:style](url)Text[/button]
- markdownToBlocks ONLY parsed OLD syntax: [card type="..."], [button url="..."]
This caused buttons/cards to be lost when:
1. User adds button in Visual mode
2. blocksToMarkdown converts to [button:solid]({url})Text[/button]
3. handleBlocksChange stores this in markdownContent
4. When switching tabs/previewing, markdownToBlocks runs
5. It FAILED to parse new syntax, buttons disappear!
FIX: Added handlers for NEW syntax in markdownToBlocks (converter.ts):
- [card:type]...[/card] pattern (before old syntax)
- [button:style](url)Text[/button] pattern (before old syntax)
Now both syntaxes work correctly in round-trip conversion.
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
import { EmailBlock, CardBlock, ButtonBlock, ImageBlock, SpacerBlock, CardType, ButtonStyle, ContentWidth, ContentAlign } from './types';
|
|
|
|
/**
|
|
* Convert HTML tags to markdown
|
|
*/
|
|
function convertHtmlToMarkdown(html: string): string {
|
|
let markdown = html;
|
|
|
|
// Headings
|
|
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
|
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
|
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
|
markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
|
|
|
|
// 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)');
|
|
|
|
// Paragraphs
|
|
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
|
|
|
// Line breaks
|
|
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
|
|
|
// 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`);
|
|
});
|
|
|
|
// Clean up extra newlines
|
|
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
|
|
|
return markdown.trim();
|
|
}
|
|
|
|
/**
|
|
* Convert blocks directly to clean markdown (no HTML pollution)
|
|
*/
|
|
export function blocksToMarkdown(blocks: EmailBlock[]): string {
|
|
return blocks.map(block => {
|
|
switch (block.type) {
|
|
case 'card': {
|
|
const cardBlock = block as CardBlock;
|
|
// Use new [card:type] syntax
|
|
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
|
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
|
}
|
|
|
|
case 'button': {
|
|
const buttonBlock = block as ButtonBlock;
|
|
// Use new [button:style](url)Text[/button] syntax
|
|
const style = buttonBlock.style || 'solid';
|
|
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
|
}
|
|
|
|
case 'image': {
|
|
const imageBlock = block as ImageBlock;
|
|
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
|
}
|
|
|
|
case 'divider':
|
|
return '---';
|
|
|
|
case 'spacer': {
|
|
const spacerBlock = block as SpacerBlock;
|
|
return `[spacer height="${spacerBlock.height}"]`;
|
|
}
|
|
|
|
default:
|
|
return '';
|
|
}
|
|
}).join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Convert blocks to [card] syntax HTML
|
|
*/
|
|
export function blocksToHTML(blocks: EmailBlock[]): string {
|
|
return blocks.map(block => {
|
|
switch (block.type) {
|
|
case 'card':
|
|
if (block.cardType === 'default') {
|
|
return `[card]\n${block.content}\n[/card]`;
|
|
}
|
|
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
|
|
|
case 'button': {
|
|
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
|
const align = block.align || 'center';
|
|
let linkStyle = '';
|
|
if (block.widthMode === 'full') {
|
|
linkStyle = 'display:block;width:100%;text-align:center;';
|
|
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
|
linkStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;margin:0 auto;`;
|
|
}
|
|
const styleAttr = linkStyle ? ` style="${linkStyle}"` : '';
|
|
return `<p style="text-align: ${align};"><a href="${block.link}" class="${buttonClass}"${styleAttr}>${block.text}</a></p>`;
|
|
}
|
|
|
|
case 'image': {
|
|
let wrapperStyle = `text-align: ${block.align};`;
|
|
let imgStyle = '';
|
|
if (block.widthMode === 'full') {
|
|
imgStyle = 'display:block;width:100%;height:auto;';
|
|
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
|
imgStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;height:auto;margin:0 auto;`;
|
|
}
|
|
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
|
}
|
|
|
|
case 'divider':
|
|
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
|
|
|
case 'spacer':
|
|
return `<div style="height: ${block.height}px;"></div>`;
|
|
|
|
default:
|
|
return '';
|
|
}
|
|
}).join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Convert [card] syntax HTML or <div class="card"> HTML to blocks
|
|
*/
|
|
export function htmlToBlocks(html: string): EmailBlock[] {
|
|
const blocks: EmailBlock[] = [];
|
|
let blockId = 0;
|
|
|
|
// Match both [card] syntax and <div class="card"> HTML
|
|
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
|
const parts: string[] = [];
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
while ((match = cardRegex.exec(html)) !== null) {
|
|
// Add content before card
|
|
if (match.index > lastIndex) {
|
|
const beforeContent = html.substring(lastIndex, match.index).trim();
|
|
if (beforeContent) parts.push(beforeContent);
|
|
}
|
|
|
|
// Add card
|
|
parts.push(match[0]);
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Add remaining content
|
|
if (lastIndex < html.length) {
|
|
const remaining = html.substring(lastIndex).trim();
|
|
if (remaining) parts.push(remaining);
|
|
}
|
|
|
|
// Process each part
|
|
for (const part of parts) {
|
|
const id = `block-${Date.now()}-${blockId++}`;
|
|
|
|
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
|
let content = '';
|
|
let cardType = 'default';
|
|
|
|
// Try new [card:type] syntax first
|
|
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
|
if (cardMatch) {
|
|
cardType = cardMatch[1];
|
|
content = cardMatch[2].trim();
|
|
} else {
|
|
// Try old [card type="..."] syntax
|
|
cardMatch = part.match(/\[card([^\]]*)\]([\s\S]*?)\[\/card\]/s);
|
|
if (cardMatch) {
|
|
const attributes = cardMatch[1];
|
|
content = cardMatch[2].trim();
|
|
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
|
cardType = (typeMatch ? typeMatch[1] : 'default');
|
|
}
|
|
}
|
|
|
|
if (!cardMatch) {
|
|
// <div class="card"> HTML syntax
|
|
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
|
if (htmlCardMatch) {
|
|
cardType = (htmlCardMatch[1] || 'default');
|
|
content = htmlCardMatch[2].trim();
|
|
}
|
|
}
|
|
|
|
if (content) {
|
|
// Convert HTML content to markdown for clean editing
|
|
// But only if it actually contains HTML tags
|
|
const hasHtmlTags = /<[^>]+>/.test(content);
|
|
const markdownContent = hasHtmlTags ? convertHtmlToMarkdown(content) : content;
|
|
blocks.push({
|
|
id,
|
|
type: 'card',
|
|
cardType: cardType as any,
|
|
content: markdownContent
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Check if it's a button - try new syntax first
|
|
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
|
if (buttonMatch) {
|
|
const style = buttonMatch[1] as ButtonStyle;
|
|
const url = buttonMatch[2];
|
|
const text = buttonMatch[3].trim();
|
|
|
|
blocks.push({
|
|
id,
|
|
type: 'button',
|
|
link: url,
|
|
text: text,
|
|
style: style,
|
|
align: 'center',
|
|
widthMode: 'fit'
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Try old [button url="..."] syntax
|
|
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
|
if (buttonMatch) {
|
|
const url = buttonMatch[1];
|
|
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
|
const text = buttonMatch[3].trim();
|
|
|
|
blocks.push({
|
|
id,
|
|
type: 'button',
|
|
link: url,
|
|
text: text,
|
|
style: style,
|
|
align: 'center',
|
|
widthMode: 'fit'
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Check HTML button syntax
|
|
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
|
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
|
part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/);
|
|
if (buttonMatch) {
|
|
const hasStyle = buttonMatch.length === 5;
|
|
const styleAttr = hasStyle ? buttonMatch[3] : '';
|
|
const textIndex = hasStyle ? 4 : 3;
|
|
const styleClassIndex = 2;
|
|
|
|
let widthMode: any = 'fit';
|
|
let customMaxWidth: number | undefined = undefined;
|
|
if (styleAttr.includes('width:100%') && !styleAttr.includes('max-width')) {
|
|
widthMode = 'full';
|
|
} else if (styleAttr.includes('max-width')) {
|
|
widthMode = 'custom';
|
|
const maxMatch = styleAttr.match(/max-width:(\d+)px/);
|
|
if (maxMatch) {
|
|
customMaxWidth = parseInt(maxMatch[1], 10);
|
|
}
|
|
}
|
|
|
|
// Extract alignment from parent <p> tag if present
|
|
const alignMatch = part.match(/text-align:\s*(left|center|right)/);
|
|
const align = alignMatch ? alignMatch[1] as any : 'center';
|
|
|
|
blocks.push({
|
|
id,
|
|
type: 'button',
|
|
text: buttonMatch[textIndex],
|
|
link: buttonMatch[1],
|
|
style: buttonMatch[styleClassIndex].includes('outline') ? 'outline' : 'solid',
|
|
widthMode,
|
|
customMaxWidth,
|
|
align,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check if it's a divider
|
|
if (part.includes('<hr')) {
|
|
blocks.push({ id, type: 'divider' });
|
|
continue;
|
|
}
|
|
|
|
// Check if it's a spacer
|
|
const spacerMatch = part.match(/height:\s*(\d+)px/);
|
|
if (spacerMatch && part.includes('<div')) {
|
|
blocks.push({ id, type: 'spacer', height: parseInt(spacerMatch[1]) });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
/**
|
|
* Convert clean markdown directly to blocks (no HTML intermediary)
|
|
*/
|
|
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|
const blocks: EmailBlock[] = [];
|
|
let blockId = 0;
|
|
|
|
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
|
let remaining = markdown;
|
|
|
|
while (remaining.length > 0) {
|
|
remaining = remaining.trim();
|
|
if (!remaining) break;
|
|
|
|
const id = `block-${Date.now()}-${blockId++}`;
|
|
|
|
// Check for [card] blocks - NEW syntax [card:type]...[/card]
|
|
const newCardMatch = remaining.match(/^\[card:(\w+)\]([\s\S]*?)\[\/card\]/);
|
|
if (newCardMatch) {
|
|
const cardType = newCardMatch[1] as CardType;
|
|
const content = newCardMatch[2].trim();
|
|
|
|
blocks.push({
|
|
id,
|
|
type: 'card',
|
|
cardType,
|
|
content,
|
|
});
|
|
|
|
remaining = remaining.substring(newCardMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for [card] blocks - OLD syntax [card type="..."]...[/card]
|
|
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
|
if (cardMatch) {
|
|
const attributes = cardMatch[1].trim();
|
|
const content = cardMatch[2].trim();
|
|
|
|
// Extract card type
|
|
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
|
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
|
|
|
// Extract background
|
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
|
const bg = bgMatch?.[1];
|
|
|
|
blocks.push({
|
|
id,
|
|
type: 'card',
|
|
cardType,
|
|
content,
|
|
bg,
|
|
});
|
|
|
|
// Advance past this card
|
|
remaining = remaining.substring(cardMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for [button] blocks - NEW syntax [button:style](url)Text[/button]
|
|
const newButtonMatch = remaining.match(/^\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
|
if (newButtonMatch) {
|
|
blocks.push({
|
|
id,
|
|
type: 'button',
|
|
text: newButtonMatch[3].trim(),
|
|
link: newButtonMatch[2],
|
|
style: newButtonMatch[1] as ButtonStyle,
|
|
align: 'center',
|
|
widthMode: 'fit',
|
|
});
|
|
|
|
remaining = remaining.substring(newButtonMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for [button] blocks - OLD syntax [button url="..." style="..."]Text[/button]
|
|
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
|
if (buttonMatch) {
|
|
blocks.push({
|
|
id,
|
|
type: 'button',
|
|
text: buttonMatch[3].trim(),
|
|
link: buttonMatch[1],
|
|
style: (buttonMatch[2] || 'solid') as ButtonStyle,
|
|
align: 'center',
|
|
widthMode: 'fit',
|
|
});
|
|
|
|
remaining = remaining.substring(buttonMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for [image] blocks
|
|
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
|
if (imageMatch) {
|
|
blocks.push({
|
|
id,
|
|
type: 'image',
|
|
src: imageMatch[1],
|
|
alt: imageMatch[2] || '',
|
|
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
|
align: (imageMatch[4] || 'center') as ContentAlign,
|
|
});
|
|
|
|
remaining = remaining.substring(imageMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for [spacer] blocks
|
|
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
|
if (spacerMatch) {
|
|
blocks.push({
|
|
id,
|
|
type: 'spacer',
|
|
height: parseInt(spacerMatch[1]),
|
|
});
|
|
|
|
remaining = remaining.substring(spacerMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for horizontal rule
|
|
if (remaining.startsWith('---')) {
|
|
blocks.push({
|
|
id,
|
|
type: 'divider',
|
|
});
|
|
|
|
remaining = remaining.substring(3);
|
|
continue;
|
|
}
|
|
|
|
// If nothing matches, skip this character to avoid infinite loop
|
|
remaining = remaining.substring(1);
|
|
}
|
|
|
|
return blocks;
|
|
}
|