From 229192440d49703450f55a600d61cf48d5b2f8f1 Mon Sep 17 00:00:00 2001 From: tianyaxiang Date: Thu, 30 Jan 2025 14:51:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A8=A1=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/editor/Editor.tsx | 10 +- src/components/editor/StyleConfigDialog.tsx | 298 +++++++++------- src/components/editor/WechatEditor.tsx | 318 ++++++++++++++++-- .../template/WechatTemplateSelector.tsx | 6 +- src/components/ui/card.tsx | 79 +++++ src/components/ui/select.tsx | 160 +++++++++ src/config/wechat-templates.ts | 232 +------------ src/lib/markdown.ts | 127 ++++--- 8 files changed, 800 insertions(+), 430 deletions(-) create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/select.tsx diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dabfbdf..ace1cc1 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -6,9 +6,17 @@ import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' import { useState } from 'react' import { TemplateSelector } from '../template/TemplateSelector' -import { templates } from '@/config/templates' import { cn } from '@/lib/utils' +const templates = [ + { + id: 'wechat', + transform: (html: string) => html, // Add your transform logic here + styles: 'wechat-specific-styles' + } + // Add more templates as needed +] + const Editor = () => { const [selectedTemplate, setSelectedTemplate] = useState('') const [preview, setPreview] = useState(false) diff --git a/src/components/editor/StyleConfigDialog.tsx b/src/components/editor/StyleConfigDialog.tsx index bb5697d..05f84c5 100644 --- a/src/components/editor/StyleConfigDialog.tsx +++ b/src/components/editor/StyleConfigDialog.tsx @@ -2,59 +2,27 @@ import { useState } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Settings } from 'lucide-react' import { type RendererOptions } from '@/lib/markdown' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -const stylePresets = { - default: { - name: '默认样式', - options: { - base: { - primaryColor: '#333333', - textAlign: 'left', - lineHeight: '1.75' - }, - block: { - h1: { fontSize: '24px', color: '#1a1a1a' }, - h2: { fontSize: '20px', color: '#1a1a1a' }, - h3: { fontSize: '18px', color: '#1a1a1a' }, - p: { fontSize: '15px', color: '#333333' }, - code_pre: { fontSize: '14px', color: '#333333' } - }, - inline: { - link: { color: '#576b95' }, - codespan: { color: '#333333' }, - em: { color: '#666666' } - } - } - }, - modern: { - name: '现代简约', - options: { - base: { - primaryColor: '#2d3748', - textAlign: 'left', - lineHeight: '1.8' - }, - block: { - h1: { fontSize: '28px', color: '#1a202c' }, - h2: { fontSize: '24px', color: '#1a202c' }, - h3: { fontSize: '20px', color: '#1a202c' }, - p: { fontSize: '16px', color: '#2d3748' }, - code_pre: { fontSize: '15px', color: '#2d3748' } - }, - inline: { - link: { color: '#4299e1' }, - codespan: { color: '#2d3748' }, - em: { color: '#718096' } - } - } - } -} +const themeColors = [ + { name: '经典黑', value: '#1a1a1a' }, + { name: '深蓝', value: '#1e40af' }, + { name: '墨绿', value: '#065f46' }, + { name: '深紫', value: '#5b21b6' }, + { name: '酒红', value: '#991b1b' }, + { name: '海蓝', value: '#0369a1' }, + { name: '森绿', value: '#166534' }, + { name: '靛蓝', value: '#1e3a8a' }, + { name: '玫红', value: '#9d174d' }, + { name: '橙色', value: '#c2410c' }, + { name: '棕褐', value: '#713f12' }, + { name: '石墨', value: '#374151' }, +] interface StyleConfigDialogProps { value: RendererOptions @@ -63,29 +31,60 @@ interface StyleConfigDialogProps { export function StyleConfigDialog({ value, onChangeAction }: StyleConfigDialogProps) { const [currentOptions, setCurrentOptions] = useState(value) - - const handlePresetChange = (preset: keyof typeof stylePresets) => { - const newOptions = stylePresets[preset].options - setCurrentOptions(newOptions) - onChangeAction(newOptions) - } + const [customizedFields, setCustomizedFields] = useState>(new Set()) const handleOptionChange = ( category: keyof RendererOptions, subcategory: string, - value: string + value: string | null ) => { + setCustomizedFields(prev => { + const next = new Set(prev) + if (value === null) { + next.delete(`${category}.${subcategory}`) + } else { + next.add(`${category}.${subcategory}`) + } + return next + }) + const newOptions = { ...currentOptions, [category]: { ...currentOptions[category], - [subcategory]: value + [subcategory]: value === null ? undefined : value } } + + // 如果是主题颜色变更,同时更新标题颜色 + if (category === 'base' && subcategory === 'themeColor') { + if (value === null) { + // 重置为模板默认值 + newOptions.block = { + ...newOptions.block, + h1: { ...(newOptions.block?.h1 || {}), color: undefined }, + h2: { ...(newOptions.block?.h2 || {}), color: undefined }, + h3: { ...(newOptions.block?.h3 || {}), color: undefined } + } + } else { + newOptions.block = { + ...newOptions.block, + h1: { ...(newOptions.block?.h1 || {}), color: value }, + h2: { ...(newOptions.block?.h2 || {}), color: value }, + h3: { ...(newOptions.block?.h3 || {}), color: value } + } + } + } + setCurrentOptions(newOptions) onChangeAction(newOptions) } + const resetToDefault = (field: string) => { + const [category, subcategory] = field.split('.') + handleOptionChange(category as keyof RendererOptions, subcategory, null) + } + return ( @@ -94,82 +93,137 @@ export function StyleConfigDialog({ value, onChangeAction }: StyleConfigDialogPr 样式设置 - + 样式配置 - - - 预设样式 - 基础 - 块级元素 - 行内元素 - - - -
- {Object.entries(stylePresets).map(([key, preset]) => ( -
handlePresetChange(key as keyof typeof stylePresets)} - > -

{preset.name}

-
-

- 正文示例 -

-

- 标题示例 -

-
-
- ))} -
-
- - -
- {currentOptions.base && Object.entries(currentOptions.base).map(([key, value]) => ( -
- - handleOptionChange('base', key, e.target.value)} + +
+
+
+
+ + {customizedFields.has('base.themeColor') && ( + + )} +
+
+ {themeColors.map((color) => ( +
- ))} + ))} +
+
+ handleOptionChange('base', 'themeColor', e.target.value)} + /> + handleOptionChange('base', 'themeColor', e.target.value)} + /> +
+

+ 此颜色将应用于一级到三级标题 +

- - -
- {currentOptions.block && Object.entries(currentOptions.block).map(([key, value]) => ( -
- - handleOptionChange('block', key, e.target.value)} - /> -
- ))} +
+
+ + {customizedFields.has('base.fontSize') && ( + + )} +
+
+ handleOptionChange('base', 'fontSize', `${e.target.value}px`)} + /> + px +
- - -
- {currentOptions.inline && Object.entries(currentOptions.inline).map(([key, value]) => ( -
- - handleOptionChange('inline', key, e.target.value)} - /> -
- ))} +
+
+ + {customizedFields.has('base.textAlign') && ( + + )} +
+
- - + +
+
+ + {customizedFields.has('base.lineHeight') && ( + + )} +
+ handleOptionChange('base', 'lineHeight', e.target.value)} + /> +
+
+
) diff --git a/src/components/editor/WechatEditor.tsx b/src/components/editor/WechatEditor.tsx index be71ef4..7dc9150 100644 --- a/src/components/editor/WechatEditor.tsx +++ b/src/components/editor/WechatEditor.tsx @@ -74,9 +74,32 @@ export default function WechatEditor() { if (!value) return '' const template = templates.find(t => t.id === selectedTemplate) - const mergedOptions = { - ...styleOptions, - ...(template?.options || {}) + const mergedOptions: RendererOptions = { + base: { + ...(template?.options?.base || {}), + ...styleOptions.base, + }, + block: { + ...(template?.options?.block || {}), + ...(styleOptions.block || {}), + // 确保标题使用主题颜色和正确的字体大小 + h1: { + fontSize: styleOptions.block?.h1?.fontSize || '24px', + color: styleOptions.base?.themeColor || '#1a1a1a' + }, + h2: { + fontSize: styleOptions.block?.h2?.fontSize || '20px', + color: styleOptions.base?.themeColor || '#1a1a1a' + }, + h3: { + fontSize: styleOptions.block?.h3?.fontSize || '18px', + color: styleOptions.base?.themeColor || '#1a1a1a' + } + }, + inline: { + ...(template?.options?.inline || {}), + ...(styleOptions.inline || {}) + } } const html = convertToWechat(value, mergedOptions) @@ -164,22 +187,6 @@ export default function WechatEditor() { } }, [toast]) - const copyContent = useCallback(() => { - const content = getPreviewContent() - navigator.clipboard.writeText(content) - .then(() => toast({ - title: "复制成功", - description: "已复制源代码到剪贴板", - duration: 2000 - })) - .catch(err => toast({ - variant: "destructive", - title: "复制失败", - description: "无法访问剪贴板,请检查浏览器权限", - action: 重试, - })) - }, [getPreviewContent, toast]) - const handleCopy = useCallback(async () => { const previewContent = previewRef.current?.querySelector('.preview-content') as HTMLElement | null if (!previewContent) { @@ -198,17 +205,268 @@ export default function WechatEditor() { const template = templates.find(t => t.id === selectedTemplate) if (template) { - tempDiv.className = template.styles - } + // 添加基础样式容器 + const styleContainer = document.createElement('div') + styleContainer.style.cssText = ` + max-width: 780px; + margin: 0 auto; + padding: 20px; + box-sizing: border-box; + ` + styleContainer.innerHTML = tempDiv.innerHTML + tempDiv.innerHTML = '' + tempDiv.appendChild(styleContainer) - if (!tempDiv.innerHTML.trim()) { - toast({ - variant: "destructive", - title: "复制失败", - description: "预览内容为空", - duration: 2000 + // 处理 CSS 变量 + const cssVariables = { + '--md-primary-color': styleOptions.base?.primaryColor || template.options.base?.primaryColor || '#333333', + '--foreground': styleOptions.base?.themeColor || template.options.base?.themeColor || '#1a1a1a', + '--background': '#ffffff', + '--muted': '#f1f5f9', + '--muted-foreground': '#64748b', + '--border': '#e2e8f0', + '--ring': '#3b82f6', + '--accent': '#f8fafc', + '--accent-foreground': '#0f172a' + } + + // 替换 CSS 变量的函数 + const replaceCSSVariables = (value: string) => { + let result = value + Object.entries(cssVariables).forEach(([variable, replaceValue]) => { + // 处理 var(--xxx) 格式 + result = result.replace( + new RegExp(`var\\(${variable}\\)`, 'g'), + replaceValue + ) + // 处理 hsl(var(--xxx)) 格式 + result = result.replace( + new RegExp(`hsl\\(var\\(${variable}\\)\\)`, 'g'), + replaceValue + ) + // 处理 rgb(var(--xxx)) 格式 + result = result.replace( + new RegExp(`rgb\\(var\\(${variable}\\)\\)`, 'g'), + replaceValue + ) + }) + return result + } + + // 合并基础样式 + const baseStyles = { + fontSize: template.options.base?.fontSize || '15px', + lineHeight: template.options.base?.lineHeight || '1.75', + textAlign: template.options.base?.textAlign || 'left', + color: template.options.base?.themeColor || '#1a1a1a', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + ...(styleOptions.base || {}) + } + + // 处理基础样式中的 CSS 变量 + Object.entries(baseStyles).forEach(([key, value]) => { + if (typeof value === 'string') { + baseStyles[key] = replaceCSSVariables(value) + } }) - return + Object.assign(styleContainer.style, baseStyles) + + // 应用基础样式到所有文本元素 + const applyBaseStyles = () => { + const textElements = styleContainer.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, li, code, strong, em, a, span') + textElements.forEach(element => { + if (element instanceof HTMLElement) { + // 如果元素没有特定的字体和字号设置,应用基础样式 + if (!element.style.fontFamily) { + element.style.fontFamily = baseStyles.fontFamily + } + if (!element.style.fontSize) { + // 根据元素类型设置不同的字号 + const tagName = element.tagName.toLowerCase() + if (tagName === 'h1') { + element.style.fontSize = '24px' + } else if (tagName === 'h2') { + element.style.fontSize = '20px' + } else if (tagName === 'h3') { + element.style.fontSize = '18px' + } else if (tagName === 'code') { + element.style.fontSize = '14px' + } else { + element.style.fontSize = baseStyles.fontSize + } + } + // 确保颜色和行高也被应用 + if (!element.style.color) { + element.style.color = baseStyles.color + } + if (!element.style.lineHeight) { + element.style.lineHeight = baseStyles.lineHeight as string + } + } + }) + } + + // 合并块级样式 + const applyBlockStyles = (selector: string, templateStyles: any, customStyles: any) => { + styleContainer.querySelectorAll(selector).forEach(element => { + const effectiveStyles: Record = {} + + // 应用模板样式 + if (templateStyles) { + Object.entries(templateStyles).forEach(([key, value]) => { + if (typeof value === 'string') { + effectiveStyles[key] = replaceCSSVariables(value) + } else { + effectiveStyles[key] = value as string + } + }) + } + + // 应用自定义样式 + if (customStyles) { + Object.entries(customStyles).forEach(([key, value]) => { + if (typeof value === 'string') { + effectiveStyles[key] = replaceCSSVariables(value) + } else { + effectiveStyles[key] = value as string + } + }) + } + + // 确保字体样式被应用 + if (!effectiveStyles.fontFamily) { + effectiveStyles.fontFamily = baseStyles.fontFamily + } + // 确保字号被应用 + if (!effectiveStyles.fontSize) { + const tagName = (element as HTMLElement).tagName.toLowerCase() + if (tagName === 'h1') { + effectiveStyles.fontSize = '24px' + } else if (tagName === 'h2') { + effectiveStyles.fontSize = '20px' + } else if (tagName === 'h3') { + effectiveStyles.fontSize = '18px' + } else if (tagName === 'code') { + effectiveStyles.fontSize = '14px' + } else { + effectiveStyles.fontSize = baseStyles.fontSize + } + } + + Object.assign((element as HTMLElement).style, effectiveStyles) + }) + } + + // 应用标题样式 + ;['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach(tag => { + applyBlockStyles( + tag, + template.options.block?.[tag], + styleOptions.block?.[tag] + ) + }) + + // 应用段落样式 + applyBlockStyles( + 'p', + template.options.block?.p, + styleOptions.block?.p + ) + + // 应用引用样式 + applyBlockStyles( + 'blockquote', + template.options.block?.blockquote, + styleOptions.block?.blockquote + ) + + // 应用代码块样式 + applyBlockStyles( + 'pre', + template.options.block?.code_pre, + styleOptions.block?.code_pre + ) + + // 应用图片样式 + applyBlockStyles( + 'img', + template.options.block?.image, + styleOptions.block?.image + ) + + // 应用列表样式 + applyBlockStyles( + 'ul', + template.options.block?.ul, + styleOptions.block?.ul + ) + applyBlockStyles( + 'ol', + template.options.block?.ol, + styleOptions.block?.ol + ) + + // 应用内联样式 + const applyInlineStyles = (selector: string, templateStyles: any, customStyles: any) => { + styleContainer.querySelectorAll(selector).forEach(element => { + const effectiveStyles: Record = {} + + // 应用模板样式 + if (templateStyles) { + Object.entries(templateStyles).forEach(([key, value]) => { + if (typeof value === 'string') { + effectiveStyles[key] = replaceCSSVariables(value) + } else { + effectiveStyles[key] = value as string + } + }) + } + + // 应用自定义样式 + if (customStyles) { + Object.entries(customStyles).forEach(([key, value]) => { + if (typeof value === 'string') { + effectiveStyles[key] = replaceCSSVariables(value) + } else { + effectiveStyles[key] = value as string + } + }) + } + + Object.assign((element as HTMLElement).style, effectiveStyles) + }) + } + + // 应用加粗样式 + applyInlineStyles( + 'strong', + template.options.inline?.strong, + styleOptions.inline?.strong + ) + + // 应用斜体样式 + applyInlineStyles( + 'em', + template.options.inline?.em, + styleOptions.inline?.em + ) + + // 应用行内代码样式 + applyInlineStyles( + 'code:not(pre code)', + template.options.inline?.codespan, + styleOptions.inline?.codespan + ) + + // 应用链接样式 + applyInlineStyles( + 'a', + template.options.inline?.link, + styleOptions.inline?.link + ) + + // 在应用所有样式后,确保基础样式被正确应用 + applyBaseStyles() } const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' }) @@ -246,7 +504,7 @@ export default function WechatEditor() { }) } } - }, [selectedTemplate, toast]) + }, [previewRef, selectedTemplate, styleOptions, toast]) // 检测是否为移动设备 const isMobile = useCallback(() => { @@ -348,7 +606,7 @@ export default function WechatEditor() { showPreview={showPreview} selectedTemplate={selectedTemplate} onSave={handleSave} - onCopy={copyContent} + onCopy={handleCopy} onCopyPreview={handleCopy} onNewArticle={handleNewArticle} onArticleSelect={handleArticleSelect} diff --git a/src/components/template/WechatTemplateSelector.tsx b/src/components/template/WechatTemplateSelector.tsx index 220e19b..a108eb4 100644 --- a/src/components/template/WechatTemplateSelector.tsx +++ b/src/components/template/WechatTemplateSelector.tsx @@ -6,12 +6,12 @@ import { cn } from "@/lib/utils" import { templates } from '@/config/wechat-templates' export function WechatTemplateSelector({ - onSelect + onSelectAction }: { - onSelect: (template: string) => void + onSelectAction: (template: string) => void }) { return ( - + diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..e855d73 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..f957126 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} \ No newline at end of file diff --git a/src/config/wechat-templates.ts b/src/config/wechat-templates.ts index daa5f3d..ad722f6 100644 --- a/src/config/wechat-templates.ts +++ b/src/config/wechat-templates.ts @@ -1,4 +1,4 @@ -import type { RendererOptions } from '@/lib/types' +import type { RendererOptions } from '@/lib/markdown' export interface Template { id: string @@ -6,7 +6,7 @@ export interface Template { description: string styles: string options: RendererOptions - transform: (html: string) => string + transform?: (html: string) => string | { html?: string; content?: string } } export const templates: Template[] = [ @@ -160,75 +160,6 @@ export const templates: Template[] = [ }, transform: (html) => html }, - { - id: 'modern', - name: '现代商务', - description: '适合商业、科技类文章', - styles: 'prose-modern', - options: { - base: { - primaryColor: '#111827', - textAlign: 'left', - lineHeight: '1.75' - }, - block: { - h1: { - fontSize: '26px', - color: '#111827', - margin: '32px 0 16px', - fontWeight: 'bold' - }, - h2: { - fontSize: '22px', - color: '#111827', - margin: '24px 0 12px', - fontWeight: 'bold' - }, - h3: { - fontSize: '18px', - color: '#111827', - margin: '20px 0 10px', - fontWeight: 'bold' - }, - p: { - fontSize: '15px', - color: '#374151', - margin: '20px 0', - lineHeight: 1.6 - }, - blockquote: { - fontSize: '15px', - color: '#4b5563', - borderLeft: '4px solid #e5e7eb', - paddingLeft: '1em', - margin: '24px 0' - }, - code_pre: { - fontSize: '14px', - background: '#f9fafb', - padding: '1em', - borderRadius: '6px', - margin: '20px 0' - } - }, - inline: { - strong: { - color: '#111827', - fontWeight: 'bold' - }, - em: { - color: '#374151', - fontStyle: 'italic' - }, - link: { - color: '#2563eb', - textDecoration: 'underline' - } - } - }, - transform: (html) => html - - }, { id: 'creative', name: '创意活力', @@ -298,164 +229,5 @@ export const templates: Template[] = [ } }, transform: (html) => html - }, - { - id: 'minimal', - name: '极简风格', - description: '简约清新的设计风格', - styles: 'prose-minimal', - options: { - base: { - primaryColor: '#000000', - textAlign: 'left', - lineHeight: '1.8' - }, - block: { - h1: { - fontSize: '24px', - color: '#000000', - margin: '36px 0 18px', - fontWeight: '500' - }, - h2: { - fontSize: '20px', - color: '#000000', - margin: '28px 0 14px', - fontWeight: '500' - }, - h3: { - fontSize: '18px', - color: '#000000', - margin: '24px 0 12px', - fontWeight: '500' - }, - p: { - fontSize: '15px', - color: '#1a1a1a', - margin: '24px 0', - lineHeight: 1.8 - }, - blockquote: { - borderLeft: '2px solid #000', - margin: '24px 0', - paddingLeft: '1.5em', - color: '#666666' - }, - code_pre: { - fontSize: '14px', - color: '#1a1a1a', - background: '#f5f5f5', - padding: '1em', - borderRadius: '4px' - } - }, - inline: { - strong: { - color: '#000000', - fontWeight: '600' - }, - em: { - color: '#666666', - fontStyle: 'italic' - }, - link: { - color: '#0066cc', - textDecoration: 'underline' - } - } - }, - transform: (html) => html - - }, - { - id: 'ios-notes', - name: '备忘录风格', - description: '仿 iOS 备忘录风格', - styles: 'prose-ios-notes', - options: { - base: { - primaryColor: '#FF9500', - textAlign: 'left', - lineHeight: '1.6' - }, - block: { - h1: { - fontSize: '24px', - color: '#1C1C1E', - margin: '32px 0 16px', - fontWeight: '600', - fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text"' - }, - h2: { - fontSize: '20px', - color: '#1C1C1E', - margin: '24px 0 12px', - fontWeight: '600', - fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text"' - }, - h3: { - fontSize: '18px', - color: '#1C1C1E', - margin: '20px 0 10px', - fontWeight: '600', - fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text"' - }, - p: { - fontSize: '17px', - color: '#333333', - margin: '16px 0', - lineHeight: 1.6, - fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text"' - }, - blockquote: { - fontSize: '17px', - color: '#666666', - borderLeft: '4px solid #FF9500', - background: '#FAFAFA', - padding: '12px 16px', - margin: '16px 0', - borderRadius: '4px' - }, - code_pre: { - fontSize: '15px', - background: '#F2F2F7', - padding: '12px 16px', - borderRadius: '8px', - margin: '16px 0', - fontFamily: 'Menlo, Monaco, "SF Mono", monospace' - }, - ul: { - paddingLeft: '24px', - margin: '16px 0' - }, - ol: { - paddingLeft: '24px', - margin: '16px 0' - } - }, - inline: { - strong: { - color: '#1C1C1E', - fontWeight: '600' - }, - em: { - color: '#666666', - fontStyle: 'italic' - }, - link: { - color: '#007AFF', - textDecoration: 'none' - }, - codespan: { - color: '#E73C3E', - background: '#F2F2F7', - padding: '2px 6px', - borderRadius: '4px', - fontSize: '90%', - fontFamily: 'Menlo, Monaco, "SF Mono", monospace' - } - } - }, - transform: (html) => html } ] \ No newline at end of file diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index c7ff7f6..195422a 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -2,22 +2,15 @@ import { Marked } from 'marked' import type { CSSProperties } from 'react' import type { Tokens } from 'marked' -// 将 React CSSProperties 转换为 CSS 字符串 -function cssPropertiesToString(style: React.CSSProperties = {}): string { +// 将样式对象转换为 CSS 字符串 +function cssPropertiesToString(style: StyleOptions = {}): string { return Object.entries(style) + .filter(([_, value]) => value !== undefined) .map(([key, value]) => { // 转换驼峰命名为连字符命名 const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase() - // 处理对象类型的值 - if (value && typeof value === 'object') { - if ('toString' in value) { - return `${cssKey}: ${value.toString()}` - } - return `${cssKey}: ${JSON.stringify(value)}` - } return `${cssKey}: ${value}` }) - .filter(Boolean) .join(';') } @@ -34,6 +27,9 @@ function baseStylesToString(base: RendererOptions['base'] = {}): string { if (base.lineHeight) { styles.push(`line-height: ${base.lineHeight}`) } + if (base.fontSize) { + styles.push(`font-size: ${base.fontSize}`) + } return styles.join(';') } @@ -67,15 +63,48 @@ marked.setOptions({ renderer: baseRenderer }) -export function convertToWechat(markdown: string, options: RendererOptions = {}): string { +const defaultOptions: RendererOptions = { + base: { + primaryColor: '#333333', + textAlign: 'left', + lineHeight: '1.75', + fontSize: '15px', + themeColor: '#1a1a1a' + }, + block: { + h1: { fontSize: '24px' }, + h2: { fontSize: '20px' }, + h3: { fontSize: '18px' }, + p: { fontSize: '15px', color: '#333333' }, + code_pre: { fontSize: '14px', color: '#333333' }, + blockquote: { fontSize: '15px', color: '#666666' } + }, + inline: { + link: { color: '#576b95' }, + codespan: { color: '#333333' }, + em: { color: '#666666' } + } +} + +export function convertToWechat(markdown: string, options: RendererOptions = defaultOptions): string { // 创建渲染器 const customRenderer = new marked.Renderer() // 继承基础渲染器 Object.setPrototypeOf(customRenderer, baseRenderer) + // 合并选项 + const mergedOptions = { + base: { ...defaultOptions.base, ...options.base }, + block: { ...defaultOptions.block, ...options.block }, + inline: { ...defaultOptions.inline, ...options.inline } + } + customRenderer.heading = function({ text, depth }: Tokens.Heading) { - const style = options.block?.[`h${depth}` as keyof typeof options.block] + const style = { + ...mergedOptions.block?.[`h${depth}`], + color: mergedOptions.base?.themeColor // 使用主题颜色 + } const styleStr = cssPropertiesToString(style) const tokens = marked.Lexer.lexInline(text) const content = marked.Parser.parseInline(tokens, { renderer: customRenderer }) @@ -83,7 +112,7 @@ export function convertToWechat(markdown: string, options: RendererOptions = {}) } customRenderer.paragraph = function({ text }: Tokens.Paragraph) { - const style = options.block?.p + const style = mergedOptions.block?.p const styleStr = cssPropertiesToString(style) const tokens = marked.Lexer.lexInline(text) const content = marked.Parser.parseInline(tokens, { renderer: customRenderer }) @@ -91,43 +120,43 @@ export function convertToWechat(markdown: string, options: RendererOptions = {}) } customRenderer.blockquote = function({ text }: Tokens.Blockquote) { - const style = options.block?.blockquote + const style = mergedOptions.block?.blockquote const styleStr = cssPropertiesToString(style) return `${text}` } customRenderer.code = function({ text, lang }: Tokens.Code) { - const style = options.block?.code_pre + const style = mergedOptions.block?.code_pre const styleStr = cssPropertiesToString(style) return `${text}` } customRenderer.codespan = function({ text }: Tokens.Codespan) { - const style = options.inline?.codespan + const style = mergedOptions.inline?.codespan const styleStr = cssPropertiesToString(style) return `${text}` } customRenderer.em = function({ text }: Tokens.Em) { - const style = options.inline?.em + const style = mergedOptions.inline?.em const styleStr = cssPropertiesToString(style) return `${text}` } customRenderer.strong = function({ text }: Tokens.Strong) { - const style = options.inline?.strong + const style = mergedOptions.inline?.strong const styleStr = cssPropertiesToString(style) return `${text}` } customRenderer.link = function({ href, title, text }: Tokens.Link) { - const style = options.inline?.link + const style = mergedOptions.inline?.link const styleStr = cssPropertiesToString(style) return `${text}` } customRenderer.image = function({ href, title, text }: Tokens.Image) { - const style = options.block?.image + const style = mergedOptions.block?.image const styleStr = cssPropertiesToString(style) return `${text}` } @@ -136,7 +165,7 @@ export function convertToWechat(markdown: string, options: RendererOptions = {}) customRenderer.list = function(token: Tokens.List): string { const tag = token.ordered ? 'ol' : 'ul' try { - const style = options.block?.[token.ordered ? 'ol' : 'ul'] + const style = mergedOptions.block?.[token.ordered ? 'ol' : 'ul'] const styleStr = cssPropertiesToString(style) const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '' @@ -150,7 +179,7 @@ customRenderer.list = function(token: Tokens.List): string { // 重写 listitem 方法 customRenderer.listitem = function(item: Tokens.ListItem) { try { - const style = options.inline?.listitem + const style = mergedOptions.inline?.listitem const styleStr = cssPropertiesToString(style) // 移除列表项开头的破折号和空格 @@ -180,7 +209,7 @@ customRenderer.listitem = function(item: Tokens.ListItem) { const html = marked.parse(markdown, { renderer: customRenderer }) as string // Apply base styles - const baseStyles = baseStylesToString(options.base) + const baseStyles = baseStylesToString(mergedOptions.base) return baseStyles ? `
${html}
` : html } @@ -212,33 +241,43 @@ export function convertToXiaohongshu(markdown: string): string { return marked.parse(markdown, { renderer }) as string } -type RendererOptions = { +export interface StyleOptions { + fontSize?: string + color?: string + margin?: string + padding?: string + border?: string + borderLeft?: string + borderBottom?: string + borderRadius?: string + background?: string + fontWeight?: string + fontStyle?: string + textDecoration?: string + display?: string + lineHeight?: string | number + textAlign?: string + paddingLeft?: string + overflowX?: string + width?: string + letterSpacing?: string + fontFamily?: string + WebkitBackgroundClip?: string + WebkitTextFillColor?: string +} + +export interface RendererOptions { base?: { primaryColor?: string textAlign?: string lineHeight?: string | number + fontSize?: string + themeColor?: string } block?: { - h1?: CSSProperties - h2?: CSSProperties - h3?: CSSProperties - h4?: CSSProperties - h5?: CSSProperties - h6?: CSSProperties - p?: CSSProperties - blockquote?: CSSProperties - code_pre?: CSSProperties - image?: CSSProperties - ul?: CSSProperties - ol?: CSSProperties + [key: string]: StyleOptions } inline?: { - strong?: CSSProperties - em?: CSSProperties - codespan?: CSSProperties - link?: CSSProperties - listitem?: CSSProperties + [key: string]: StyleOptions } -} - -export type { RendererOptions } \ No newline at end of file +} \ No newline at end of file