优化模版
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