优化模版

This commit is contained in:
tianyaxiang 2025-01-30 14:51:23 +08:00
parent c2a8ebbd01
commit 229192440d
8 changed files with 800 additions and 430 deletions

View File

@ -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)

View File

@ -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>
)

View File

@ -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}

View File

@ -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>

View 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 }

View 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,
}

View File

@ -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
}
]

View File

@ -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 }