优化模版
This commit is contained in:
		
							parent
							
								
									c2a8ebbd01
								
							
						
					
					
						commit
						229192440d
					
				| @ -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<string>('') | ||||
|   const [preview, setPreview] = useState(false) | ||||
|  | ||||
| @ -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<RendererOptions>(value) | ||||
| 
 | ||||
|   const handlePresetChange = (preset: keyof typeof stylePresets) => { | ||||
|     const newOptions = stylePresets[preset].options | ||||
|     setCurrentOptions(newOptions) | ||||
|     onChangeAction(newOptions) | ||||
|   } | ||||
|   const [customizedFields, setCustomizedFields] = useState<Set<string>>(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 ( | ||||
|     <Dialog> | ||||
|       <DialogTrigger asChild> | ||||
| @ -94,82 +93,137 @@ export function StyleConfigDialog({ value, onChangeAction }: StyleConfigDialogPr | ||||
|           样式设置 | ||||
|         </Button> | ||||
|       </DialogTrigger> | ||||
|       <DialogContent className="max-w-2xl"> | ||||
|       <DialogContent className="max-w-md"> | ||||
|         <DialogHeader> | ||||
|           <DialogTitle>样式配置</DialogTitle> | ||||
|         </DialogHeader> | ||||
|         <Tabs defaultValue="presets" className="w-full"> | ||||
|           <TabsList> | ||||
|             <TabsTrigger value="presets">预设样式</TabsTrigger> | ||||
|             <TabsTrigger value="base">基础</TabsTrigger> | ||||
|             <TabsTrigger value="block">块级元素</TabsTrigger> | ||||
|             <TabsTrigger value="inline">行内元素</TabsTrigger> | ||||
|           </TabsList> | ||||
| 
 | ||||
|           <TabsContent value="presets" className="space-y-4"> | ||||
|             <div className="grid grid-cols-2 gap-4"> | ||||
|               {Object.entries(stylePresets).map(([key, preset]) => ( | ||||
|                 <div | ||||
|                   key={key} | ||||
|                   className="p-4 border rounded-lg cursor-pointer hover:border-primary" | ||||
|                   onClick={() => handlePresetChange(key as keyof typeof stylePresets)} | ||||
|                 > | ||||
|                   <h3 className="font-medium mb-2">{preset.name}</h3> | ||||
|                   <div className="space-y-2"> | ||||
|                     <p style={{ fontSize: preset.options.block?.p?.fontSize, color: preset.options.block?.p?.color }}> | ||||
|                       正文示例 | ||||
|                     </p> | ||||
|                     <h2 style={{ fontSize: preset.options.block?.h2?.fontSize, color: preset.options.block?.h2?.color }}> | ||||
|                       标题示例 | ||||
|                     </h2> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               ))} | ||||
|             </div> | ||||
|           </TabsContent> | ||||
| 
 | ||||
|           <TabsContent value="base" className="space-y-4"> | ||||
|             <div className="grid grid-cols-2 gap-4"> | ||||
|               {currentOptions.base && Object.entries(currentOptions.base).map(([key, value]) => ( | ||||
|                 <div key={key} className="space-y-2"> | ||||
|                   <Label>{key}</Label> | ||||
|                   <Input | ||||
|                     value={value} | ||||
|                     onChange={(e) => handleOptionChange('base', key, e.target.value)} | ||||
|          | ||||
|         <div className="space-y-6"> | ||||
|           <div className="space-y-4"> | ||||
|             <div className="space-y-2"> | ||||
|               <div className="flex items-center justify-between"> | ||||
|                 <Label>主题颜色(标题)</Label> | ||||
|                 {customizedFields.has('base.themeColor') && ( | ||||
|                   <Button  | ||||
|                     variant="ghost"  | ||||
|                     size="sm" | ||||
|                     onClick={() => resetToDefault('base.themeColor')} | ||||
|                   > | ||||
|                     重置 | ||||
|                   </Button> | ||||
|                 )} | ||||
|               </div> | ||||
|               <div className="grid grid-cols-6 gap-2 mb-2"> | ||||
|                 {themeColors.map((color) => ( | ||||
|                   <button | ||||
|                     key={color.value} | ||||
|                     className={`w-8 h-8 rounded-full border-2 transition-all ${ | ||||
|                       currentOptions.base?.themeColor === color.value | ||||
|                         ? 'border-primary scale-110' | ||||
|                         : 'border-transparent hover:scale-105' | ||||
|                     }`}
 | ||||
|                     style={{ backgroundColor: color.value }} | ||||
|                     onClick={() => handleOptionChange('base', 'themeColor', color.value)} | ||||
|                     title={color.name} | ||||
|                   /> | ||||
|                 </div> | ||||
|               ))} | ||||
|                 ))} | ||||
|               </div> | ||||
|               <div className="flex gap-2"> | ||||
|                 <Input | ||||
|                   type="color" | ||||
|                   value={currentOptions.base?.themeColor || '#1a1a1a'} | ||||
|                   className="w-16 h-8 p-1" | ||||
|                   onChange={(e) => handleOptionChange('base', 'themeColor', e.target.value)} | ||||
|                 /> | ||||
|                 <Input | ||||
|                   value={currentOptions.base?.themeColor || '#1a1a1a'} | ||||
|                   onChange={(e) => handleOptionChange('base', 'themeColor', e.target.value)} | ||||
|                 /> | ||||
|               </div> | ||||
|               <p className="text-sm text-muted-foreground"> | ||||
|                 此颜色将应用于一级到三级标题 | ||||
|               </p> | ||||
|             </div> | ||||
|           </TabsContent> | ||||
| 
 | ||||
|           <TabsContent value="block" className="space-y-4"> | ||||
|             <div className="grid grid-cols-2 gap-4"> | ||||
|               {currentOptions.block && Object.entries(currentOptions.block).map(([key, value]) => ( | ||||
|                 <div key={key} className="space-y-2"> | ||||
|                   <Label>{key}</Label> | ||||
|                   <Input | ||||
|                     value={JSON.stringify(value)} | ||||
|                     onChange={(e) => handleOptionChange('block', key, e.target.value)} | ||||
|                   /> | ||||
|                 </div> | ||||
|               ))} | ||||
|             <div className="space-y-2"> | ||||
|               <div className="flex items-center justify-between"> | ||||
|                 <Label>字体大小</Label> | ||||
|                 {customizedFields.has('base.fontSize') && ( | ||||
|                   <Button  | ||||
|                     variant="ghost"  | ||||
|                     size="sm" | ||||
|                     onClick={() => resetToDefault('base.fontSize')} | ||||
|                   > | ||||
|                     重置 | ||||
|                   </Button> | ||||
|                 )} | ||||
|               </div> | ||||
|               <div className="flex gap-2"> | ||||
|                 <Input | ||||
|                   type="number" | ||||
|                   min="12" | ||||
|                   max="24" | ||||
|                   value={parseInt(currentOptions.base?.fontSize || '15')} | ||||
|                   className="w-24" | ||||
|                   onChange={(e) => handleOptionChange('base', 'fontSize', `${e.target.value}px`)} | ||||
|                 /> | ||||
|                 <span className="flex items-center">px</span> | ||||
|               </div> | ||||
|             </div> | ||||
|           </TabsContent> | ||||
| 
 | ||||
|           <TabsContent value="inline" className="space-y-4"> | ||||
|             <div className="grid grid-cols-2 gap-4"> | ||||
|               {currentOptions.inline && Object.entries(currentOptions.inline).map(([key, value]) => ( | ||||
|                 <div key={key} className="space-y-2"> | ||||
|                   <Label>{key}</Label> | ||||
|                   <Input | ||||
|                     value={JSON.stringify(value)} | ||||
|                     onChange={(e) => handleOptionChange('inline', key, e.target.value)} | ||||
|                   /> | ||||
|                 </div> | ||||
|               ))} | ||||
|             <div className="space-y-2"> | ||||
|               <div className="flex items-center justify-between"> | ||||
|                 <Label>文本对齐</Label> | ||||
|                 {customizedFields.has('base.textAlign') && ( | ||||
|                   <Button  | ||||
|                     variant="ghost"  | ||||
|                     size="sm" | ||||
|                     onClick={() => resetToDefault('base.textAlign')} | ||||
|                   > | ||||
|                     重置 | ||||
|                   </Button> | ||||
|                 )} | ||||
|               </div> | ||||
|               <Select  | ||||
|                 value={currentOptions.base?.textAlign || 'left'} | ||||
|                 onValueChange={(value: string) => handleOptionChange('base', 'textAlign', value)} | ||||
|               > | ||||
|                 <SelectTrigger> | ||||
|                   <SelectValue /> | ||||
|                 </SelectTrigger> | ||||
|                 <SelectContent> | ||||
|                   <SelectItem value="left">左对齐</SelectItem> | ||||
|                   <SelectItem value="center">居中对齐</SelectItem> | ||||
|                   <SelectItem value="right">右对齐</SelectItem> | ||||
|                   <SelectItem value="justify">两端对齐</SelectItem> | ||||
|                 </SelectContent> | ||||
|               </Select> | ||||
|             </div> | ||||
|           </TabsContent> | ||||
|         </Tabs> | ||||
| 
 | ||||
|             <div className="space-y-2"> | ||||
|               <div className="flex items-center justify-between"> | ||||
|                 <Label>行高</Label> | ||||
|                 {customizedFields.has('base.lineHeight') && ( | ||||
|                   <Button  | ||||
|                     variant="ghost"  | ||||
|                     size="sm" | ||||
|                     onClick={() => resetToDefault('base.lineHeight')} | ||||
|                   > | ||||
|                     重置 | ||||
|                   </Button> | ||||
|                 )} | ||||
|               </div> | ||||
|               <Input | ||||
|                 type="number" | ||||
|                 min="1" | ||||
|                 max="3" | ||||
|                 step="0.1" | ||||
|                 value={parseFloat(String(currentOptions.base?.lineHeight || '1.75'))} | ||||
|                 onChange={(e) => handleOptionChange('base', 'lineHeight', e.target.value)} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ) | ||||
|  | ||||
| @ -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: <ToastAction altText="重试">重试</ToastAction>, | ||||
|       })) | ||||
|   }, [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<string, string> = {} | ||||
| 
 | ||||
|             // 应用模板样式
 | ||||
|             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<string, string> = {} | ||||
| 
 | ||||
|             // 应用模板样式
 | ||||
|             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} | ||||
|  | ||||
| @ -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 ( | ||||
|     <SelectPrimitive.Root onValueChange={onSelect}> | ||||
|     <SelectPrimitive.Root onValueChange={onSelectAction}> | ||||
|       <SelectPrimitive.Trigger className="inline-flex items-center justify-between rounded-md px-3 py-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground min-w-[120px]"> | ||||
|         <SelectPrimitive.Value placeholder="选择样式..." /> | ||||
|         <SelectPrimitive.Icon> | ||||
|  | ||||
							
								
								
									
										79
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,79 @@ | ||||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Card = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "rounded-lg border bg-card text-card-foreground shadow-sm", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Card.displayName = "Card" | ||||
| 
 | ||||
| const CardHeader = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("flex flex-col space-y-1.5 p-6", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardHeader.displayName = "CardHeader" | ||||
| 
 | ||||
| const CardTitle = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLHeadingElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <h3 | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "text-2xl font-semibold leading-none tracking-tight", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardTitle.displayName = "CardTitle" | ||||
| 
 | ||||
| const CardDescription = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLParagraphElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <p | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardDescription.displayName = "CardDescription" | ||||
| 
 | ||||
| const CardContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> | ||||
| )) | ||||
| CardContent.displayName = "CardContent" | ||||
| 
 | ||||
| const CardFooter = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("flex items-center p-6 pt-0", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardFooter.displayName = "CardFooter" | ||||
| 
 | ||||
| export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }  | ||||
							
								
								
									
										160
									
								
								src/components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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<typeof SelectPrimitive.Trigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Trigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {children} | ||||
|     <SelectPrimitive.Icon asChild> | ||||
|       <ChevronDown className="h-4 w-4 opacity-50" /> | ||||
|     </SelectPrimitive.Icon> | ||||
|   </SelectPrimitive.Trigger> | ||||
| )) | ||||
| SelectTrigger.displayName = SelectPrimitive.Trigger.displayName | ||||
| 
 | ||||
| const SelectScrollUpButton = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.ScrollUpButton | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default items-center justify-center py-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <ChevronUp className="h-4 w-4" /> | ||||
|   </SelectPrimitive.ScrollUpButton> | ||||
| )) | ||||
| SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName | ||||
| 
 | ||||
| const SelectScrollDownButton = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.ScrollDownButton | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default items-center justify-center py-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <ChevronDown className="h-4 w-4" /> | ||||
|   </SelectPrimitive.ScrollDownButton> | ||||
| )) | ||||
| SelectScrollDownButton.displayName = | ||||
|   SelectPrimitive.ScrollDownButton.displayName | ||||
| 
 | ||||
| const SelectContent = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> | ||||
| >(({ className, children, position = "popper", ...props }, ref) => ( | ||||
|   <SelectPrimitive.Portal> | ||||
|     <SelectPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|         position === "popper" && | ||||
|           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", | ||||
|         className | ||||
|       )} | ||||
|       position={position} | ||||
|       {...props} | ||||
|     > | ||||
|       <SelectScrollUpButton /> | ||||
|       <SelectPrimitive.Viewport | ||||
|         className={cn( | ||||
|           "p-1", | ||||
|           position === "popper" && | ||||
|             "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" | ||||
|         )} | ||||
|       > | ||||
|         {children} | ||||
|       </SelectPrimitive.Viewport> | ||||
|       <SelectScrollDownButton /> | ||||
|     </SelectPrimitive.Content> | ||||
|   </SelectPrimitive.Portal> | ||||
| )) | ||||
| SelectContent.displayName = SelectPrimitive.Content.displayName | ||||
| 
 | ||||
| const SelectLabel = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Label>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Label | ||||
|     ref={ref} | ||||
|     className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SelectLabel.displayName = SelectPrimitive.Label.displayName | ||||
| 
 | ||||
| const SelectItem = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <SelectPrimitive.ItemIndicator> | ||||
|         <Check className="h-4 w-4" /> | ||||
|       </SelectPrimitive.ItemIndicator> | ||||
|     </span> | ||||
| 
 | ||||
|     <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> | ||||
|   </SelectPrimitive.Item> | ||||
| )) | ||||
| SelectItem.displayName = SelectPrimitive.Item.displayName | ||||
| 
 | ||||
| const SelectSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Separator>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Separator | ||||
|     ref={ref} | ||||
|     className={cn("-mx-1 my-1 h-px bg-muted", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SelectSeparator.displayName = SelectPrimitive.Separator.displayName | ||||
| 
 | ||||
| export { | ||||
|   Select, | ||||
|   SelectGroup, | ||||
|   SelectValue, | ||||
|   SelectTrigger, | ||||
|   SelectContent, | ||||
|   SelectLabel, | ||||
|   SelectItem, | ||||
|   SelectSeparator, | ||||
|   SelectScrollUpButton, | ||||
|   SelectScrollDownButton, | ||||
| }  | ||||
| @ -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 | ||||
|   } | ||||
| ]  | ||||
| @ -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 `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${text}</blockquote>` | ||||
|   } | ||||
| 
 | ||||
|   customRenderer.code = function({ text, lang }: Tokens.Code) { | ||||
|     const style = options.block?.code_pre | ||||
|     const style = mergedOptions.block?.code_pre | ||||
|     const styleStr = cssPropertiesToString(style) | ||||
|     return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${text}</code></pre>` | ||||
|   } | ||||
| 
 | ||||
|   customRenderer.codespan = function({ text }: Tokens.Codespan) { | ||||
|     const style = options.inline?.codespan | ||||
|     const style = mergedOptions.inline?.codespan | ||||
|     const styleStr = cssPropertiesToString(style) | ||||
|     return `<code${styleStr ? ` style="${styleStr}"` : ''}>${text}</code>` | ||||
|   } | ||||
| 
 | ||||
|   customRenderer.em = function({ text }: Tokens.Em) { | ||||
|     const style = options.inline?.em | ||||
|     const style = mergedOptions.inline?.em | ||||
|     const styleStr = cssPropertiesToString(style) | ||||
|     return `<em${styleStr ? ` style="${styleStr}"` : ''}>${text}</em>` | ||||
|   } | ||||
| 
 | ||||
|   customRenderer.strong = function({ text }: Tokens.Strong) { | ||||
|     const style = options.inline?.strong | ||||
|     const style = mergedOptions.inline?.strong | ||||
|     const styleStr = cssPropertiesToString(style) | ||||
|     return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${text}</strong>` | ||||
|   } | ||||
| 
 | ||||
|   customRenderer.link = function({ href, title, text }: Tokens.Link) { | ||||
|     const style = options.inline?.link | ||||
|     const style = mergedOptions.inline?.link | ||||
|     const styleStr = cssPropertiesToString(style) | ||||
|     return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>` | ||||
|   } | ||||
| 
 | ||||
|   customRenderer.image = function({ href, title, text }: Tokens.Image) { | ||||
|     const style = options.block?.image | ||||
|     const style = mergedOptions.block?.image | ||||
|     const styleStr = cssPropertiesToString(style) | ||||
|     return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''} />` | ||||
|   } | ||||
| @ -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 ? `<div style="${baseStyles}">${html}</div>` : 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 } | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 tianyaxiang
						tianyaxiang