重构代码

This commit is contained in:
tianyaxiang 2025-02-03 21:23:47 +08:00
parent 764add8de7
commit af36766729
19 changed files with 1075 additions and 779 deletions

View File

@ -30,6 +30,7 @@
"@types/marked": "^6.0.0", "@types/marked": "^6.0.0",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clipboard-polyfill": "^4.1.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"katex": "^0.16.21", "katex": "^0.16.21",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",

View File

@ -71,6 +71,9 @@ importers:
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
clipboard-polyfill:
specifier: ^4.1.1
version: 4.1.1
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -1110,6 +1113,9 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clipboard-polyfill@4.1.1:
resolution: {integrity: sha512-nbvNLrcX0zviek5QHLFRAaLrx8y/s8+RF2stH43tuS+kP5XlHMrcD0UGBWq43Hwp6WuuK7KefRMP56S45ibZkA==}
clone@1.0.4: clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -3229,6 +3235,8 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
clipboard-polyfill@4.1.1: {}
clone@1.0.4: {} clone@1.0.4: {}
clsx@2.1.1: {} clsx@2.1.1: {}

View File

@ -1,217 +1,45 @@
'use client' 'use client'
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import { templates } from '@/config/wechat-templates'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown' import { type RendererOptions } from '@/lib/markdown'
import { useEditorSync } from './hooks/useEditorSync' import { useEditorSync } from './hooks/useEditorSync'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { EditorToolbar } from './components/EditorToolbar' import { EditorToolbar } from './components/EditorToolbar'
import { EditorPreview } from './components/EditorPreview' import { EditorPreview } from './components/EditorPreview'
import { MarkdownToolbar } from './components/MarkdownToolbar' import { MarkdownToolbar } from './components/MarkdownToolbar'
import { type PreviewSize } from './constants' import { type PreviewSize } from './constants'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { WechatStylePicker } from '@/components/template/WechatStylePicker'
import { Copy, Clock, Type, Trash2 } from 'lucide-react'
import { useLocalStorage } from '@/hooks/use-local-storage' import { useLocalStorage } from '@/hooks/use-local-storage'
import { codeThemes, type CodeThemeId } from '@/config/code-themes' import { codeThemes, type CodeThemeId } from '@/config/code-themes'
import '@/styles/code-themes.css' import '@/styles/code-themes.css'
import { copyHandler, initializeMermaid, MERMAID_CONFIG } from './utils/copy-handler' import { templates } from '@/config/wechat-templates'
import { cn } from '@/lib/utils'
// 计算阅读时间假设每分钟阅读300字 import { usePreviewContent } from './hooks/usePreviewContent'
const calculateReadingTime = (text: string): string => { import { useEditorKeyboard } from './hooks/useEditorKeyboard'
const words = text.trim().length import { useScrollSync } from './hooks/useScrollSync'
const minutes = Math.ceil(words / 300) import { useWordStats } from './hooks/useWordStats'
return `${minutes} 分钟` import { useCopy } from './hooks/useCopy'
}
// 计算字数
const calculateWordCount = (text: string): string => {
const count = text.trim().length
return count.toLocaleString()
}
export default function WechatEditor() { export default function WechatEditor() {
const { toast } = useToast() const { toast } = useToast()
const editorRef = useRef<HTMLDivElement>(null) const editorRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
// 状态管理
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<string>('default') const [selectedTemplate, setSelectedTemplate] = useState<string>('default')
const [showPreview, setShowPreview] = useState(true) const [showPreview, setShowPreview] = useState(true)
const [styleOptions, setStyleOptions] = useState<RendererOptions>({}) const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium') const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
const [isConverting, setIsConverting] = useState(false)
const [isDraft, setIsDraft] = useState(false) const [isDraft, setIsDraft] = useState(false)
const [previewContent, setPreviewContent] = useState('')
const [cursorPosition, setCursorPosition] = useState<{ start: number; end: number }>({ start: 0, end: 0 })
// 添加字数和阅读时间状态
const [wordCount, setWordCount] = useState('0')
const [readingTime, setReadingTime] = useState('1 分钟')
// 添加 codeTheme 状态
const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id) const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
// 使用自定义 hooks // 使用自定义 hooks
const { handleScroll } = useEditorSync(editorRef) const { handleScroll } = useEditorSync(editorRef)
const { handleEditorChange } = useAutoSave(value, setIsDraft) const { handleEditorChange } = useAutoSave(value, setIsDraft)
const { handleEditorScroll } = useScrollSync()
// 初始化 Mermaid
useEffect(() => {
const init = async () => {
await initializeMermaid()
}
init()
}, [])
// 处理编辑器输入
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const currentPosition = {
start: e.target.selectionStart,
end: e.target.selectionEnd,
scrollTop: e.target.scrollTop // 保存当前滚动位置
}
setValue(newValue)
handleEditorChange(newValue)
// 使用 requestAnimationFrame 确保在下一帧恢复位置
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.scrollTop = currentPosition.scrollTop // 恢复滚动位置
textareaRef.current.setSelectionRange(currentPosition.start, currentPosition.end)
}
})
}, [handleEditorChange])
// 处理Tab键
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault()
const textarea = e.currentTarget
const start = textarea.selectionStart
const end = textarea.selectionEnd
// 插入两个空格作为缩进
const newValue = value.substring(0, start) + ' ' + value.substring(end)
setValue(newValue)
handleEditorChange(newValue)
// 恢复光标位置
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2
})
}
}, [value, handleEditorChange])
// 获取预览内容
const getPreviewContent = useCallback(() => {
if (!value) return ''
const template = templates.find(t => t.id === selectedTemplate)
const mergedOptions: RendererOptions = {
base: {
...(template?.options?.base || {}),
...styleOptions.base,
},
block: {
...(template?.options?.block || {}),
...(styleOptions.block || {}),
code_pre: {
...(template?.options?.block?.code_pre || {}),
...(styleOptions.block?.code_pre || {}),
...getCodeThemeStyles(codeTheme)
},
h1: {
...(template?.options?.block?.h1 || {}),
...(styleOptions.block?.h1 || {}),
fontSize: styleOptions.block?.h1?.fontSize || template?.options?.block?.h1?.fontSize || '24px',
color: styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a',
...(template?.options?.block?.h1?.borderBottom && {
borderBottom: `2px solid ${styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a'}`
})
},
h2: {
...(template?.options?.block?.h2 || {}),
...(styleOptions.block?.h2 || {}),
fontSize: styleOptions.block?.h2?.fontSize || template?.options?.block?.h2?.fontSize || '20px',
color: styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a',
...(template?.options?.block?.h2?.borderBottom && {
borderBottom: `2px solid ${styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a'}`
})
},
h3: {
...(template?.options?.block?.h3 || {}),
...(styleOptions.block?.h3 || {}),
fontSize: styleOptions.block?.h3?.fontSize || template?.options?.block?.h3?.fontSize || '1.1em',
color: styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a',
...(template?.options?.block?.h3?.borderLeft && {
borderLeft: `3px solid ${styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a'}`
})
},
p: {
...(template?.options?.block?.p || {}),
...(styleOptions.block?.p || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
lineHeight: styleOptions.base?.lineHeight || template?.options?.base?.lineHeight || 2
},
ol: {
...(template?.options?.block?.ol || {}),
...(styleOptions.block?.ol || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
},
ul: {
...(template?.options?.block?.ul || {}),
...(styleOptions.block?.ul || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
}
},
inline: {
...(template?.options?.inline || {}),
...(styleOptions.inline || {}),
listitem: {
...(template?.options?.inline?.listitem || {}),
...(styleOptions.inline?.listitem || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
}
},
codeTheme
}
const html = convertToWechat(value, mergedOptions)
if (!template?.transform) return html
try {
const transformed = template.transform(html)
if (transformed && typeof transformed === 'object') {
const result = transformed as { html?: string; content?: string }
if (result.html) return result.html
if (result.content) return result.content
return JSON.stringify(transformed)
}
return transformed || html
} catch (error) {
console.error('Template transformation error:', error)
return html
}
}, [value, selectedTemplate, styleOptions, codeTheme])
// 处理复制
const handleCopy = useCallback(async () => {
// 获取预览容器中的实际内容
const previewContainer = document.querySelector('.preview-content')
if (!previewContainer) {
return copyHandler(window.getSelection(), previewContent, { toast })
}
// 使用实际渲染后的内容
return copyHandler(window.getSelection(), previewContainer.innerHTML, { toast })
}, [previewContent, toast])
// 手动保存 // 手动保存
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@ -233,78 +61,53 @@ export default function WechatEditor() {
} }
}, [value, toast]) }, [value, toast])
// 监听快捷键保存事件 const { isConverting, previewContent } = usePreviewContent({
useEffect(() => { value,
const handleKeyDown = (e: KeyboardEvent) => { selectedTemplate,
if ((e.metaKey || e.ctrlKey) && e.key === 's') { styleOptions,
e.preventDefault() codeTheme
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSave])
// 更新预览内容
useEffect(() => {
const updatePreview = async () => {
if (!value) {
setPreviewContent('')
return
}
setIsConverting(true)
try {
const content = getPreviewContent()
setPreviewContent(content)
// 等待下一个渲染周期,确保内容已更新到 DOM
await new Promise(resolve => requestAnimationFrame(resolve))
// 重新初始化 Mermaid但不阻塞界面
initializeMermaid().catch(error => {
console.error('Failed to initialize Mermaid:', error)
// 不显示错误提示,让界面继续运行
}) })
} catch (error) {
console.error('Error updating preview:', error) const { handleKeyDown } = useEditorKeyboard({
toast({ value,
variant: "destructive", onChange: (newValue) => {
title: "预览更新失败", setValue(newValue)
description: "生成预览内容时发生错误", handleEditorChange(newValue)
},
onSave: handleSave
}) })
} finally {
setIsConverting(false) // 处理编辑器输入
} const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const currentPosition = {
start: e.target.selectionStart,
end: e.target.selectionEnd,
scrollTop: e.target.scrollTop
} }
updatePreview() setValue(newValue)
}, [value, selectedTemplate, styleOptions, codeTheme, getPreviewContent, toast]) handleEditorChange(newValue)
// 加载已保存的内容 requestAnimationFrame(() => {
useEffect(() => { if (textareaRef.current) {
const draftContent = localStorage.getItem('wechat_editor_draft') textareaRef.current.scrollTop = currentPosition.scrollTop
const savedContent = localStorage.getItem('wechat_editor_content') textareaRef.current.setSelectionRange(currentPosition.start, currentPosition.end)
}
if (draftContent) {
setValue(draftContent)
setIsDraft(true)
toast({
description: "已恢复未保存的草稿",
action: <ToastAction altText="放弃" onClick={handleDiscardDraft}>稿</ToastAction>,
duration: 5000,
}) })
} else if (savedContent) { }, [handleEditorChange])
setValue(savedContent)
} const { handleCopy } = useCopy()
}, [toast])
// 处理复制
const onCopy = useCallback(async () => {
return handleCopy(window.getSelection(), previewContent)
}, [handleCopy, previewContent])
// 处理放弃草稿 // 处理放弃草稿
const handleDiscardDraft = useCallback(() => { const handleDiscardDraft = useCallback(() => {
const savedContent = localStorage.getItem('wechat_editor_content') const savedContent = localStorage.getItem('wechat_editor_content')
// 移除草稿
localStorage.removeItem('wechat_editor_draft') localStorage.removeItem('wechat_editor_draft')
// 恢复到最后保存的内容,如果没有则清空
setValue(savedContent || '') setValue(savedContent || '')
setIsDraft(false) setIsDraft(false)
toast({ toast({
@ -314,35 +117,6 @@ export default function WechatEditor() {
}) })
}, [toast]) }, [toast])
// 渲染预览内容
const renderPreview = useCallback(() => {
return (
<div
className="preview-content"
dangerouslySetInnerHTML={{ __html: previewContent }}
/>
)
}, [previewContent])
// 检测是否为移动设备
const isMobile = useCallback(() => {
if (typeof window === 'undefined') return false
return window.innerWidth < 640
}, [])
// 自动切换预览模式
useEffect(() => {
const handleResize = () => {
if (isMobile()) {
setPreviewSize('full')
}
}
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [isMobile])
// 处理文章选择 // 处理文章选择
const handleArticleSelect = useCallback((article: { content: string, template: string }) => { const handleArticleSelect = useCallback((article: { content: string, template: string }) => {
setValue(article.content) setValue(article.content)
@ -391,13 +165,11 @@ export default function WechatEditor() {
let newCursorPos = 0 let newCursorPos = 0
if (options?.wrap && selectedText) { if (options?.wrap && selectedText) {
// 如果有选中文本且需要包裹
newText = value.substring(0, start) + newText = value.substring(0, start) +
text + selectedText + (options.suffix || text) + text + selectedText + (options.suffix || text) +
value.substring(end) value.substring(end)
newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length) newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length)
} else { } else {
// 插入新文本
const insertText = selectedText || options?.placeholder || '' const insertText = selectedText || options?.placeholder || ''
newText = value.substring(0, start) + newText = value.substring(0, start) +
text + insertText + (options?.suffix || '') + text + insertText + (options?.suffix || '') +
@ -408,7 +180,6 @@ export default function WechatEditor() {
setValue(newText) setValue(newText)
handleEditorChange(newText) handleEditorChange(newText)
// 恢复焦点并设置光标位置
requestAnimationFrame(() => { requestAnimationFrame(() => {
textarea.focus() textarea.focus()
textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.setSelectionRange(newCursorPos, newCursorPos)
@ -418,71 +189,9 @@ export default function WechatEditor() {
// 处理模版选择 // 处理模版选择
const handleTemplateSelect = useCallback((templateId: string) => { const handleTemplateSelect = useCallback((templateId: string) => {
setSelectedTemplate(templateId) setSelectedTemplate(templateId)
// 重置样式设置为模版默认值
setStyleOptions({}) setStyleOptions({})
}, []) }, [])
// 更新字数和阅读时间
useEffect(() => {
const plainText = previewContent.replace(/<[^>]+>/g, '')
setWordCount(calculateWordCount(plainText))
setReadingTime(calculateReadingTime(plainText))
}, [previewContent])
const isScrolling = useRef<boolean>(false)
const scrollTimeout = useRef<NodeJS.Timeout>()
const lastScrollTop = useRef<number>(0)
// 处理滚动同步
const handleEditorScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
// 如果是由于输入导致的滚动,不进行同步
if (e.currentTarget.selectionStart !== e.currentTarget.selectionEnd) {
return;
}
if (isScrolling.current) return
const textarea = e.currentTarget
const previewContainer = document.querySelector('.preview-container .overflow-y-auto')
if (!previewContainer) return
// 检查滚动方向和幅度
const currentScrollTop = textarea.scrollTop
const scrollDiff = currentScrollTop - lastScrollTop.current
// 如果滚动幅度太小,忽略此次滚动
if (Math.abs(scrollDiff) < 5) return
isScrolling.current = true
lastScrollTop.current = currentScrollTop
try {
const scrollPercentage = currentScrollTop / (textarea.scrollHeight - textarea.clientHeight)
const targetScrollTop = scrollPercentage * (previewContainer.scrollHeight - previewContainer.clientHeight)
previewContainer.scrollTo({
top: targetScrollTop,
behavior: 'instant'
})
} finally {
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current)
}
scrollTimeout.current = setTimeout(() => {
isScrolling.current = false
}, 50)
}
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current)
}
}
}, [])
// 清除编辑器内容 // 清除编辑器内容
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
if (window.confirm('确定要清除所有内容吗?')) { if (window.confirm('确定要清除所有内容吗?')) {
@ -496,25 +205,44 @@ export default function WechatEditor() {
} }
}, [handleEditorChange, toast]) }, [handleEditorChange, toast])
// 处理代码主题变化 // 检测是否为移动设备
const handleCodeThemeChange = useCallback((theme: CodeThemeId) => { const isMobile = useCallback(() => {
setCodeTheme(theme) if (typeof window === 'undefined') return false
// 立即重新生成预览内容 return window.innerWidth < 640
setIsConverting(true) }, [])
try {
const content = getPreviewContent() // 自动切换预览模式
setPreviewContent(content) useEffect(() => {
} catch (error) { const handleResize = () => {
console.error('Error updating preview:', error) if (isMobile()) {
toast({ setPreviewSize('full')
variant: "destructive",
title: "预览更新失败",
description: "生成预览内容时发生错误",
})
} finally {
setIsConverting(false)
} }
}, [getPreviewContent, toast]) }
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [isMobile])
// 加载已保存的内容
useEffect(() => {
const draftContent = localStorage.getItem('wechat_editor_draft')
const savedContent = localStorage.getItem('wechat_editor_content')
if (draftContent) {
setValue(draftContent)
setIsDraft(true)
toast({
description: "已恢复未保存的草稿",
action: <ToastAction altText="放弃" onClick={handleDiscardDraft}>稿</ToastAction>,
duration: 5000,
})
} else if (savedContent) {
setValue(savedContent)
}
}, [toast, handleDiscardDraft])
const { wordCount, readingTime } = useWordStats(value)
return ( return (
<div className="h-screen flex flex-col overflow-hidden"> <div className="h-screen flex flex-col overflow-hidden">
@ -525,8 +253,8 @@ export default function WechatEditor() {
showPreview={showPreview} showPreview={showPreview}
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
onSave={handleSave} onSave={handleSave}
onCopy={handleCopy} onCopy={onCopy}
onCopyPreview={handleCopy} onCopyPreview={onCopy}
onNewArticle={handleNewArticle} onNewArticle={handleNewArticle}
onArticleSelect={handleArticleSelect} onArticleSelect={handleArticleSelect}
onTemplateSelect={handleTemplateSelect} onTemplateSelect={handleTemplateSelect}
@ -537,7 +265,7 @@ export default function WechatEditor() {
wordCount={wordCount} wordCount={wordCount}
readingTime={readingTime} readingTime={readingTime}
codeTheme={codeTheme} codeTheme={codeTheme}
onCodeThemeChange={handleCodeThemeChange} onCodeThemeChange={setCodeTheme}
/> />
</div> </div>
@ -546,10 +274,17 @@ export default function WechatEditor() {
<div className="sm:hidden flex-1 flex flex-col"> <div className="sm:hidden flex-1 flex flex-col">
<div className="flex items-center justify-between p-2 border-b bg-background"> <div className="flex items-center justify-between p-2 border-b bg-background">
<div className="flex-1 mr-2"> <div className="flex-1 mr-2">
<WechatStylePicker <select
value={selectedTemplate} value={selectedTemplate}
onSelect={handleTemplateSelect} onChange={(e) => handleTemplateSelect(e.target.value)}
/> className="w-full p-2 rounded-md border"
>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -557,24 +292,18 @@ export default function WechatEditor() {
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-destructive hover:bg-muted transition-colors" className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-destructive hover:bg-muted transition-colors"
title="清除内容" title="清除内容"
> >
<Trash2 className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={handleCopy} onClick={onCopy}
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-primary hover:bg-muted transition-colors" className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-primary hover:bg-muted transition-colors"
> >
<Copy className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
<Tabs defaultValue="editor" className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<TabsList className="grid w-full grid-cols-2"> <div className="flex-1">
<TabsTrigger value="editor"></TabsTrigger>
<TabsTrigger value="preview"></TabsTrigger>
</TabsList>
<TabsContent value="editor" className="flex-1 data-[state=inactive]:hidden">
<div <div
ref={editorRef} ref={editorRef}
className={cn( className={cn(
@ -587,15 +316,14 @@ export default function WechatEditor() {
value={value} value={value}
onChange={handleInput} onChange={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onScroll={handleEditorScroll}
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none" className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
placeholder="开始写作..." placeholder="开始写作..."
spellCheck={false} spellCheck={false}
onScroll={handleEditorScroll}
/> />
</div> </div>
</TabsContent> </div>
<TabsContent value="preview" className="flex-1 data-[state=inactive]:hidden"> <div className="flex-1">
<div className="h-full overflow-y-auto">
<EditorPreview <EditorPreview
previewRef={previewRef} previewRef={previewRef}
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
@ -606,8 +334,7 @@ export default function WechatEditor() {
onPreviewSizeChange={setPreviewSize} onPreviewSizeChange={setPreviewSize}
/> />
</div> </div>
</TabsContent> </div>
</Tabs>
</div> </div>
{/* Desktop Split View */} {/* Desktop Split View */}
@ -629,10 +356,10 @@ export default function WechatEditor() {
value={value} value={value}
onChange={handleInput} onChange={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onScroll={handleEditorScroll}
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none" className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
placeholder="开始写作..." placeholder="开始写作..."
spellCheck={false} spellCheck={false}
onScroll={handleEditorScroll}
/> />
</div> </div>
</div> </div>
@ -651,14 +378,12 @@ export default function WechatEditor() {
</div> </div>
</div> </div>
{/* 底部工具栏 */} {/* 底部状态栏 */}
<div className="fixed bottom-0 left-0 right-0 bg-background border-t h-10 flex items-center justify-end px-4 gap-4"> <div className="fixed bottom-0 left-0 right-0 bg-background border-t h-10 flex items-center justify-end px-4 gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Type className="h-4 w-4" />
<span>{wordCount} </span> <span>{wordCount} </span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mr-4"> <div className="flex items-center gap-2 text-sm text-muted-foreground mr-4">
<Clock className="h-4 w-4" />
<span> {readingTime}</span> <span> {readingTime}</span>
</div> </div>
</div> </div>

View File

@ -0,0 +1 @@

View File

@ -1,11 +1,14 @@
'use client'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { PREVIEW_SIZES, type PreviewSize } from '../constants' import { PREVIEW_SIZES, type PreviewSize } from '../constants'
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react' import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
import { templates } from '@/config/wechat-templates' import { templates } from '@/config/wechat-templates'
import { useState, useRef, useEffect, useMemo } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import { type CodeThemeId } from '@/config/code-themes' import { type CodeThemeId } from '@/config/code-themes'
import { initMermaid } from '@/lib/markdown/mermaid-init' import { useTheme } from 'next-themes'
import '@/styles/code-themes.css' import '@/styles/code-themes.css'
import mermaid from 'mermaid'
interface EditorPreviewProps { interface EditorPreviewProps {
previewRef: React.RefObject<HTMLDivElement> previewRef: React.RefObject<HTMLDivElement>
@ -29,106 +32,133 @@ export function EditorPreview({
const [zoom, setZoom] = useState(100) const [zoom, setZoom] = useState(100)
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const isScrolling = useRef<boolean>(false) const isScrolling = useRef<boolean>(false)
const contentRef = useRef<string>('') const { theme } = useTheme()
const renderTimeoutRef = useRef<number>()
const stableKeyRef = useRef(`preview-${Date.now()}`)
// Add useEffect to handle content changes and Mermaid initialization // 初始化 Mermaid
useEffect(() => { useEffect(() => {
if (!isConverting && previewContent) { mermaid.initialize({
// Clear any pending render timeout theme: theme === 'dark' ? 'dark' : 'default',
if (renderTimeoutRef.current) { startOnLoad: false,
window.clearTimeout(renderTimeoutRef.current) securityLevel: 'loose',
fontFamily: 'var(--font-sans)',
fontSize: 14,
flowchart: {
htmlLabels: true,
curve: 'basis',
padding: 15,
useMaxWidth: false,
defaultRenderer: 'dagre-d3'
},
sequence: {
useMaxWidth: false,
boxMargin: 10,
mirrorActors: false,
bottomMarginAdj: 2,
rightAngles: true,
showSequenceNumbers: false
},
pie: {
useMaxWidth: true,
textPosition: 0.5,
useWidth: 800
},
gantt: {
useMaxWidth: false,
leftPadding: 75,
rightPadding: 20
} }
// Set a new timeout to render after content has settled
renderTimeoutRef.current = window.setTimeout(() => {
requestAnimationFrame(() => {
initMermaid().catch(error => {
console.error('Failed to initialize mermaid:', error)
}) })
}) }, [theme])
}, 100) // Wait for 100ms after last content change
}
// Cleanup timeout on unmount // 使用 memo 包装预览内容
return () => { const PreviewContent = useMemo(() => {
if (renderTimeoutRef.current) { return (
window.clearTimeout(renderTimeoutRef.current) <div className={cn(
} "preview-content py-4",
} "prose prose-slate dark:prose-invert max-w-none",
}, [isConverting, previewContent]) selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div
className="px-6"
dangerouslySetInnerHTML={{ __html: previewContent }}
/>
</div>
)
}, [previewContent, selectedTemplate])
// Add useEffect to handle theme changes // 渲染 Mermaid 图表
useEffect(() => { useEffect(() => {
if (document.querySelector('div.mermaid')) { const renderMermaid = async () => {
if (renderTimeoutRef.current) {
window.clearTimeout(renderTimeoutRef.current)
}
renderTimeoutRef.current = window.setTimeout(() => {
requestAnimationFrame(() => {
initMermaid().catch(error => {
console.error('Failed to initialize mermaid after theme change:', error)
})
})
}, 100)
}
}, [codeTheme])
// Add useEffect to handle copy events
useEffect(() => {
const handleCopy = async (e: ClipboardEvent) => {
const selection = window.getSelection()
if (!selection) return
const selectedNode = selection.anchorNode?.parentElement
if (!selectedNode) return
// 检查是否在 mermaid 图表内
const mermaidElement = selectedNode.closest('.mermaid')
if (mermaidElement) {
e.preventDefault()
// 获取渲染后的 SVG 元素
const svgElement = mermaidElement.querySelector('svg')
if (svgElement) {
try { try {
// 创建一个临时的 div 来包含 SVG const elements = document.querySelectorAll('.mermaid')
const container = document.createElement('div') if (!elements.length) return
container.appendChild(svgElement.cloneNode(true))
// 准备 HTML 和纯文本格式 // 重新初始化所有图表
const htmlContent = container.innerHTML await Promise.all(Array.from(elements).map(async (element) => {
const plainText = mermaidElement.querySelector('.mermaid-source')?.textContent || '' try {
// 获取内容
const content = element.textContent?.trim() || ''
if (!content) return
// 尝试复制为 HTML保留图表效果 // 清空容器
await navigator.clipboard.write([ element.innerHTML = ''
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }), // 重新渲染
'text/plain': new Blob([plainText], { type: 'text/plain' }) const { svg } = await mermaid.render(
}) `mermaid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
]) content
)
// 更新内容
element.innerHTML = svg
// 添加暗色模式支持
if (theme === 'dark') {
const svgElement = element.querySelector('svg')
if (svgElement) {
svgElement.style.filter = 'invert(0.85)'
}
}
} catch (error) { } catch (error) {
// 如果复制 HTML 失败,退回到复制源代码 console.error('Failed to render mermaid diagram:', {
console.error('Failed to copy as HTML:', error) error,
const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || '' element,
if (e.clipboardData) { content: element.textContent
e.clipboardData.setData('text/plain', sourceText) })
} element.innerHTML = `
} <div class="rounded-lg overflow-hidden border border-red-200">
} else { <div class="bg-red-50 p-3 text-red-700 text-sm">
// 如果找不到 SVG退回到复制源代码 Failed to render diagram
const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || '' </div>
if (e.clipboardData) { <pre class="bg-white p-3 m-0 text-sm overflow-x-auto whitespace-pre-wrap break-all">
e.clipboardData.setData('text/plain', sourceText) ${element.textContent || ''}
} </pre>
<div class="bg-red-50 p-3 text-red-600 text-sm border-t border-red-200">
${error instanceof Error ? error.message : 'Unknown error'}
</div>
</div>
`
} }
}))
} catch (error) {
console.error('Failed to initialize mermaid diagrams:', error)
} }
} }
document.addEventListener('copy', handleCopy) if (!isConverting) {
return () => document.removeEventListener('copy', handleCopy) renderMermaid()
}
}, [previewContent, theme, isConverting])
// 监听全屏状态变化
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, []) }, [])
const handleZoomIn = () => { const handleZoomIn = () => {
@ -142,24 +172,11 @@ export function EditorPreview({
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
previewRef.current?.requestFullscreen() previewRef.current?.requestFullscreen()
setIsFullscreen(true)
} else { } else {
document.exitFullscreen() document.exitFullscreen()
setIsFullscreen(false)
} }
} }
// 使用 memo 包装预览内容
const PreviewContent = useMemo(() => (
<div className={cn(
"preview-content py-4",
"prose prose-slate dark:prose-invert max-w-none",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="px-6" dangerouslySetInnerHTML={{ __html: previewContent }} />
</div>
), [previewContent, selectedTemplate])
return ( return (
<div <div
ref={previewRef} ref={previewRef}
@ -170,7 +187,6 @@ export function EditorPreview({
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles, selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
`code-theme-${codeTheme}` `code-theme-${codeTheme}`
)} )}
key={stableKeyRef.current}
> >
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0"> <div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
<div className="flex items-center gap-0.5 px-2"> <div className="flex items-center gap-0.5 px-2">
@ -218,7 +234,9 @@ export function EditorPreview({
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto" onScroll={(e) => { <div
className="flex-1 overflow-y-auto"
onScroll={(e) => {
const container = e.currentTarget const container = e.currentTarget
const textarea = document.querySelector('.editor-container textarea') const textarea = document.querySelector('.editor-container textarea')
if (!textarea || isScrolling.current) return if (!textarea || isScrolling.current) return
@ -233,7 +251,8 @@ export function EditorPreview({
isScrolling.current = false isScrolling.current = false
}) })
} }
}}> }}
>
<div className="h-full py-8 px-4"> <div className="h-full py-8 px-4">
<div <div
className={cn( className={cn(

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,90 @@
import { useCallback } from 'react'
import { useToast } from '@/components/ui/use-toast'
import { initializeMermaid } from '@/lib/markdown/mermaid-utils'
export const useCopy = () => {
const { toast } = useToast()
const copyToClipboard = useCallback(async (content: string) => {
try {
// 创建临时容器并渲染内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = content
document.body.appendChild(tempDiv)
// 处理 Mermaid 图表
const mermaidElements = tempDiv.querySelectorAll('.mermaid')
if (mermaidElements.length > 0) {
// 重新初始化 Mermaid
await initializeMermaid()
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 100))
}
// 获取处理后的内容
const processedContent = tempDiv.innerHTML
// 清理临时 div
document.body.removeChild(tempDiv)
// 使用 Clipboard API 写入富文本
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([processedContent], { type: 'text/html' }),
'text/plain': new Blob([content.replace(/<[^>]+>/g, '')], { type: 'text/plain' })
})
])
toast({
title: "复制成功",
description: "内容已复制,可直接粘贴到公众号编辑器",
duration: 2000
})
return true
} catch (error) {
console.error('Copy error:', error)
try {
// 降级处理:尝试以纯文本方式复制
await navigator.clipboard.writeText(content.replace(/<[^>]+>/g, ''))
toast({
title: "复制成功",
description: "已复制为纯文本内容",
duration: 2000
})
return true
} catch (fallbackError) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
duration: 2000
})
return false
}
}
}, [toast])
const handleCopy = useCallback(async (selection: Selection | null, content: string) => {
try {
// 如果有选中的文本,使用选中的内容
if (selection && !selection.isCollapsed) {
const range = selection.getRangeAt(0)
const container = range.cloneContents()
const div = document.createElement('div')
div.appendChild(container)
return await copyToClipboard(div.innerHTML)
}
// 否则复制整个内容
return await copyToClipboard(content)
} catch (error) {
console.error('Handle copy error:', error)
return false
}
}, [copyToClipboard])
return {
handleCopy,
copyToClipboard
}
}

View File

@ -0,0 +1,47 @@
import { useCallback, useEffect } from 'react'
interface UseEditorKeyboardProps {
value: string
onChange: (value: string) => void
onSave: () => void
}
export const useEditorKeyboard = ({
value,
onChange,
onSave
}: UseEditorKeyboardProps) => {
// 处理Tab键
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault()
const textarea = e.currentTarget
const start = textarea.selectionStart
const end = textarea.selectionEnd
// 插入两个空格作为缩进
const newValue = value.substring(0, start) + ' ' + value.substring(end)
onChange(newValue)
// 恢复光标位置
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2
})
}
}, [value, onChange])
// 监听快捷键保存事件
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
onSave()
}
}
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [onSave])
return { handleKeyDown }
}

View File

@ -0,0 +1,158 @@
import { useState, useCallback, useEffect } from 'react'
import { templates } from '@/config/wechat-templates'
import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown'
import { type CodeThemeId } from '@/config/code-themes'
import { useToast } from '@/components/ui/use-toast'
import { initializeMermaid } from '@/lib/markdown/mermaid-utils'
interface UsePreviewContentProps {
value: string
selectedTemplate: string
styleOptions: RendererOptions
codeTheme: CodeThemeId
}
export const usePreviewContent = ({
value,
selectedTemplate,
styleOptions,
codeTheme
}: UsePreviewContentProps) => {
const { toast } = useToast()
const [isConverting, setIsConverting] = useState(false)
const [previewContent, setPreviewContent] = useState('')
const getPreviewContent = useCallback(() => {
if (!value) return ''
const template = templates.find(t => t.id === selectedTemplate)
const mergedOptions: RendererOptions = {
base: {
...(template?.options?.base || {}),
...styleOptions.base,
},
block: {
...(template?.options?.block || {}),
...(styleOptions.block || {}),
code_pre: {
...(template?.options?.block?.code_pre || {}),
...(styleOptions.block?.code_pre || {}),
...getCodeThemeStyles(codeTheme)
},
h1: {
...(template?.options?.block?.h1 || {}),
...(styleOptions.block?.h1 || {}),
fontSize: styleOptions.block?.h1?.fontSize || template?.options?.block?.h1?.fontSize || '24px',
color: styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a',
...(template?.options?.block?.h1?.borderBottom && {
borderBottom: `2px solid ${styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a'}`
})
},
h2: {
...(template?.options?.block?.h2 || {}),
...(styleOptions.block?.h2 || {}),
fontSize: styleOptions.block?.h2?.fontSize || template?.options?.block?.h2?.fontSize || '20px',
color: styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a',
...(template?.options?.block?.h2?.borderBottom && {
borderBottom: `2px solid ${styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a'}`
})
},
h3: {
...(template?.options?.block?.h3 || {}),
...(styleOptions.block?.h3 || {}),
fontSize: styleOptions.block?.h3?.fontSize || template?.options?.block?.h3?.fontSize || '1.1em',
color: styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a',
...(template?.options?.block?.h3?.borderLeft && {
borderLeft: `3px solid ${styleOptions.base?.themeColor || template?.options?.base?.themeColor || '#1a1a1a'}`
})
},
p: {
...(template?.options?.block?.p || {}),
...(styleOptions.block?.p || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
lineHeight: styleOptions.base?.lineHeight || template?.options?.base?.lineHeight || 2
},
ol: {
...(template?.options?.block?.ol || {}),
...(styleOptions.block?.ol || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
},
ul: {
...(template?.options?.block?.ul || {}),
...(styleOptions.block?.ul || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
}
},
inline: {
...(template?.options?.inline || {}),
...(styleOptions.inline || {}),
listitem: {
...(template?.options?.inline?.listitem || {}),
...(styleOptions.inline?.listitem || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
}
},
codeTheme
}
const html = convertToWechat(value, mergedOptions)
if (!template?.transform) return html
try {
const transformed = template.transform(html)
if (transformed && typeof transformed === 'object') {
const result = transformed as { html?: string; content?: string }
if (result.html) return result.html
if (result.content) return result.content
return JSON.stringify(transformed)
}
return transformed || html
} catch (error) {
console.error('Template transformation error:', error)
return html
}
}, [value, selectedTemplate, styleOptions, codeTheme])
useEffect(() => {
const updatePreview = async () => {
if (!value) {
setPreviewContent('')
return
}
setIsConverting(true)
try {
const content = getPreviewContent()
setPreviewContent(content)
// 等待 DOM 更新
await new Promise(resolve => setTimeout(resolve, 50))
// 渲染 Mermaid 图表
try {
await initializeMermaid()
} catch (error) {
console.error('Failed to initialize Mermaid:', error)
}
} catch (error) {
console.error('Error updating preview:', error)
toast({
variant: "destructive",
title: "预览更新失败",
description: "生成预览内容时发生错误",
})
} finally {
setIsConverting(false)
}
}
updatePreview()
}, [value, selectedTemplate, styleOptions, codeTheme, getPreviewContent, toast])
return {
isConverting,
previewContent,
getPreviewContent
}
}

View File

@ -0,0 +1,49 @@
import { useCallback, useRef } from 'react'
export const useScrollSync = () => {
const isScrolling = useRef<boolean>(false)
const scrollTimeout = useRef<NodeJS.Timeout>()
const lastScrollTop = useRef<number>(0)
const handleEditorScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
// 如果是由于输入导致的滚动,不进行同步
if (e.currentTarget.selectionStart !== e.currentTarget.selectionEnd) {
return
}
if (isScrolling.current) return
const textarea = e.currentTarget
const previewContainer = document.querySelector('.preview-container .overflow-y-auto')
if (!previewContainer) return
// 检查滚动方向和幅度
const currentScrollTop = textarea.scrollTop
const scrollDiff = currentScrollTop - lastScrollTop.current
// 如果滚动幅度太小,忽略此次滚动
if (Math.abs(scrollDiff) < 5) return
isScrolling.current = true
lastScrollTop.current = currentScrollTop
try {
const scrollPercentage = currentScrollTop / (textarea.scrollHeight - textarea.clientHeight)
const targetScrollTop = scrollPercentage * (previewContainer.scrollHeight - previewContainer.clientHeight)
previewContainer.scrollTo({
top: targetScrollTop,
behavior: 'instant'
})
} finally {
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current)
}
scrollTimeout.current = setTimeout(() => {
isScrolling.current = false
}, 50)
}
}, [])
return { handleEditorScroll }
}

View File

@ -0,0 +1,27 @@
import { useState, useEffect } from 'react'
// 计算阅读时间假设每分钟阅读300字
const calculateReadingTime = (text: string): string => {
const words = text.trim().length
const minutes = Math.ceil(words / 300)
return `${minutes} 分钟`
}
// 计算字数
const calculateWordCount = (text: string): string => {
const count = text.trim().length
return count.toLocaleString()
}
export const useWordStats = (content: string) => {
const [wordCount, setWordCount] = useState('0')
const [readingTime, setReadingTime] = useState('1 分钟')
useEffect(() => {
const plainText = content.replace(/<[^>]+>/g, '')
setWordCount(calculateWordCount(plainText))
setReadingTime(calculateReadingTime(plainText))
}, [content])
return { wordCount, readingTime }
}

View File

@ -1,266 +0,0 @@
import React from "react"
import { initMermaid } from '@/lib/markdown/mermaid-init'
declare global {
interface Window {
mermaid: {
init: (config: any, nodes: NodeListOf<Element>) => Promise<void>
initialize: (config: any) => void
}
}
}
// Mermaid 配置
export const MERMAID_CONFIG = {
theme: 'neutral',
themeVariables: {
primaryColor: '#f5f8fe',
primaryBorderColor: '#c9e0ff',
primaryTextColor: '#000000',
lineColor: '#000000',
textColor: '#000000',
fontSize: '14px'
},
flowchart: {
htmlLabels: true,
curve: 'basis',
padding: 15,
nodeSpacing: 50,
rankSpacing: 50,
useMaxWidth: false
},
sequence: {
useMaxWidth: false,
boxMargin: 10,
mirrorActors: false,
bottomMarginAdj: 2
}
}
/**
* Mermaid
*/
export async function initializeMermaid() {
if (typeof window !== 'undefined') {
try {
await initMermaid()
} catch (error) {
console.error('Mermaid initialization error:', error)
// 即使初始化失败也继续执行,不阻塞整个应用
}
}
}
type ToastFunction = {
(props: {
title?: string
description?: string
action?: React.ReactElement
duration?: number
variant?: "default" | "destructive"
}): void
}
interface CopyHandlerOptions {
toast: ToastFunction
}
/**
* Mermaid
*/
async function handleMermaidCopy(mermaidElement: Element): Promise<boolean> {
const svgElement = mermaidElement.querySelector('svg')
if (!svgElement) return false
// 获取原始的 Mermaid 代码
const originalCode = mermaidElement.getAttribute('data-source') || ''
// 创建一个临时的 canvas 来转换 SVG 为图片
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get canvas context')
// 设置 canvas 尺寸为 SVG 的实际尺寸
const svgRect = svgElement.getBoundingClientRect()
canvas.width = svgRect.width
canvas.height = svgRect.height
// 创建图片对象
const img = new Image()
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
// 等待图片加载
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = url
})
// 绘制图片到 canvas
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
// 转换为 PNG
const pngBlob = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/png', 1.0)
})
// 清理
URL.revokeObjectURL(url)
// 复制为图片、HTML 和原始文本
await navigator.clipboard.write([
new ClipboardItem({
'image/png': pngBlob,
'text/html': new Blob([svgElement.outerHTML], { type: 'text/html' }),
'text/plain': new Blob([originalCode], { type: 'text/plain' })
})
])
return true
}
/**
*
*/
async function handlePreviewCopy(previewContent: string): Promise<boolean> {
// 创建临时容器来处理内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent
// 首先找到所有 Mermaid 图表的源代码
const mermaidElements = tempDiv.querySelectorAll('.mermaid')
if (mermaidElements.length > 0) {
try {
await initMermaid()
} catch (error) {
console.error('Mermaid rendering error:', error)
}
}
// 处理渲染后的 SVG
const mermaidDiagrams = tempDiv.querySelectorAll('.mermaid svg')
mermaidDiagrams.forEach(svg => {
const container = svg.closest('.mermaid')
if (container) {
// 设置 SVG 的样式
const svgElement = svg as SVGElement
Object.assign(svgElement.style, {
backgroundColor: 'transparent',
maxWidth: '100%',
width: '100%',
height: 'auto'
})
// 更新图表容器的样式
container.setAttribute('style', `
background-color: transparent;
padding: 0;
margin: 16px 0;
display: flex;
justify-content: center;
width: 100%;
`)
}
})
// 获取处理后的 HTML 内容
const htmlContent = tempDiv.innerHTML
const plainText = tempDiv.textContent || tempDiv.innerText
// 复制到剪贴板,添加必要的样式
const styledHtml = `
<div style="
background-color: transparent;
font-family: system-ui, -apple-system, sans-serif;
color: #000000;
line-height: 1.5;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
">
${htmlContent}
</div>
`
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([styledHtml], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' })
})
])
return true
}
/**
*
*/
export async function copyHandler(
selection: Selection | null,
previewContent: string,
options: CopyHandlerOptions
): Promise<boolean> {
const { toast } = options
try {
// 检查是否有选中的 Mermaid 图表
if (selection && !selection.isCollapsed) {
const selectedNode = selection.anchorNode?.parentElement
if (selectedNode) {
const mermaidElement = selectedNode.closest('.mermaid')
if (mermaidElement) {
const success = await handleMermaidCopy(mermaidElement)
if (success) {
toast({
title: "复制成功",
description: "已复制图表(支持粘贴为图片或源代码)",
duration: 2000
})
return true
}
}
}
}
// 复制整个预览内容
const success = await handlePreviewCopy(previewContent)
if (success) {
toast({
title: "复制成功",
description: "已复制预览内容",
duration: 2000
})
return true
}
return false
} catch (err) {
console.error('Copy error:', err)
try {
await navigator.clipboard.writeText(previewContent)
toast({
title: "复制成功",
description: "已复制预览内容(仅文本)",
duration: 2000
})
return true
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: (
<button onClick={() => window.location.reload()} className="hover:bg-secondary">
</button>
)
})
return false
}
}
}

View File

@ -0,0 +1,92 @@
'use client'
import { useEffect, useRef } from 'react'
import type { MermaidRendererProps } from '../types/mermaid'
import { getCurrentTheme, initializeMermaid, renderMermaidDiagram } from '../utils/mermaid'
import { useTheme } from 'next-themes'
export function MermaidRenderer({
content,
className = '',
config,
onRender,
onError
}: MermaidRendererProps) {
const containerRef = useRef<HTMLDivElement>(null)
const { theme } = useTheme()
useEffect(() => {
const renderDiagram = async () => {
if (!containerRef.current) return
try {
// 初始化 mermaid
const currentTheme = getCurrentTheme()
initializeMermaid(currentTheme, config)
// 清空容器内容
containerRef.current.innerHTML = ''
// 创建新的渲染容器
const renderContainer = document.createElement('div')
renderContainer.className = 'mermaid-render'
containerRef.current.appendChild(renderContainer)
// 渲染图表
const { svg } = await renderMermaidDiagram(content, config)
// 更新内容
if (containerRef.current) {
renderContainer.innerHTML = svg
// 添加原始内容的隐藏元素,用于复制等功能
const sourceElement = document.createElement('div')
sourceElement.className = 'mermaid-source'
sourceElement.style.display = 'none'
sourceElement.textContent = content
containerRef.current.appendChild(sourceElement)
}
// 调用渲染完成回调
onRender?.(svg)
} catch (error) {
console.error('Failed to render mermaid diagram:', error)
if (!containerRef.current) return
// 显示错误信息
containerRef.current.innerHTML = `
<div class="mermaid-error rounded-lg overflow-hidden border border-red-200">
<div class="bg-red-50 p-3 text-red-700 text-sm">
Mermaid
</div>
<pre class="mermaid-source bg-white p-3 m-0 text-sm overflow-x-auto whitespace-pre-wrap break-all">
${content}
</pre>
<div class="bg-red-50 p-3 text-red-600 text-sm border-t border-red-200 whitespace-pre-wrap break-all">
${error instanceof Error ? error.message : '未知错误'}
</div>
</div>
`
// 调用错误回调
if (error instanceof Error) {
onError?.({
content,
message: error.message,
error
})
}
}
}
renderDiagram()
}, [content, config, theme, onRender, onError])
return (
<div
ref={containerRef}
className={`mermaid ${className}`}
data-theme={theme}
/>
)
}

View File

@ -0,0 +1,62 @@
import type { MermaidConfig } from 'mermaid'
import type { MermaidTheme, MermaidThemeVariables } from '../types/mermaid'
const defaultThemeVariables: MermaidThemeVariables = {
primaryColor: '#4f46e5',
primaryBorderColor: '#4f46e5',
primaryTextColor: '#000000',
lineColor: '#666666',
textColor: '#333333',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif'
}
const darkThemeVariables: MermaidThemeVariables = {
primaryColor: '#6366f1',
primaryBorderColor: '#6366f1',
primaryTextColor: '#ffffff',
lineColor: '#999999',
textColor: '#ffffff',
fontSize: '14px',
fontFamily: defaultThemeVariables.fontFamily
}
export const themeVariables: Record<MermaidTheme, MermaidThemeVariables> = {
default: defaultThemeVariables,
dark: darkThemeVariables,
neutral: {
...defaultThemeVariables,
primaryColor: '#6b7280',
primaryBorderColor: '#6b7280'
},
forest: {
...defaultThemeVariables,
primaryColor: '#059669',
primaryBorderColor: '#059669'
}
}
export const createMermaidConfig = (theme: MermaidTheme = 'default'): MermaidConfig => ({
theme: 'base',
themeVariables: themeVariables[theme],
flowchart: {
htmlLabels: true,
curve: 'basis',
padding: 15,
nodeSpacing: 50,
rankSpacing: 50,
useMaxWidth: false
},
sequence: {
useMaxWidth: false,
boxMargin: 10,
mirrorActors: false,
bottomMarginAdj: 2
},
pie: {
textPosition: 0.75,
useMaxWidth: true
},
startOnLoad: false,
securityLevel: 'loose'
})

View File

@ -0,0 +1,84 @@
import mermaid from 'mermaid'
declare global {
interface Window {
mermaid: {
init: (config: any, nodes: NodeListOf<Element>) => Promise<void>
initialize: (config: any) => void
}
}
}
// Mermaid 配置
export const MERMAID_CONFIG = {
theme: 'default' as const,
themeVariables: {
primaryColor: '#4f46e5',
primaryBorderColor: '#4f46e5',
primaryTextColor: '#000000',
lineColor: '#666666',
textColor: '#333333',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif'
},
flowchart: {
htmlLabels: true,
curve: 'basis' as const,
padding: 15,
nodeSpacing: 50,
rankSpacing: 50,
useMaxWidth: false
},
sequence: {
useMaxWidth: false,
boxMargin: 10,
mirrorActors: false,
bottomMarginAdj: 2
},
pie: {
textPosition: 0.75,
useMaxWidth: true
},
startOnLoad: false,
securityLevel: 'loose' as const
}
/**
* Mermaid
*/
export const initializeMermaid = async () => {
try {
// 初始化配置
mermaid.initialize(MERMAID_CONFIG)
// 获取所有 mermaid 图表
const elements = document.querySelectorAll('.mermaid')
if (!elements.length) return
// 遍历并渲染每个图表
for (const element of Array.from(elements)) {
try {
// 如果已经渲染过,跳过
if (element.querySelector('svg')) continue
// 获取图表源码
const source = element.textContent || ''
if (!source.trim()) continue
// 渲染图表
const { svg } = await mermaid.render(
'mermaid-' + Math.random().toString(36).substring(2),
source
)
// 更新内容
element.innerHTML = svg
} catch (err) {
console.error('Failed to render mermaid diagram:', err)
element.innerHTML = `<div class="mermaid-error">图表渲染失败</div>`
}
}
} catch (err) {
console.error('Failed to initialize mermaid:', err)
}
}

View File

@ -68,6 +68,82 @@ export class MarkdownParser {
} }
}] }]
}) })
// 添加 Mermaid 支持
marked.use({
extensions: [{
name: 'mermaid',
level: 'block',
start(src: string) {
return src.match(/^```mermaid\s*\n/)?.index
},
tokenizer(src: string) {
const match = /^```mermaid\s*\n([\s\S]*?)\n\s*```/.exec(src)
if (match) {
return {
type: 'mermaid',
raw: match[0],
text: match[1].trim(),
tokens: []
}
}
return undefined
},
renderer(token: any) {
try {
const lines = token.text.split('\n')
.map((line: string) => line.trim())
.filter(Boolean) // 移除空行
if (lines.length === 0) {
return '<pre class="mermaid-error">Empty diagram content</pre>'
}
// 获取第一行作为图表类型
const firstLine = lines[0].toLowerCase()
let formattedContent: string
if (firstLine.startsWith('pie')) {
// 处理饼图
formattedContent = 'pie\n' + lines.slice(1).map((line: string) => {
if (line.toLowerCase().startsWith('title')) {
return ` title ${line.substring(5).trim()}`
}
if (line.includes(':')) {
const [key, value] = line.split(':').map((part: string) => part.trim())
const formattedKey = key.startsWith('"') && key.endsWith('"') ? key : `"${key}"`
return ` ${formattedKey}:${value}`
}
return ` ${line}`
}).join('\n')
} else {
// 其他类型的图表
formattedContent = lines.map((line: string, index: number) => {
if (index === 0) {
// 处理第一行(图表类型)
if (line.toLowerCase().includes('sequence')) {
return 'sequenceDiagram'
}
if (line.toLowerCase().includes('flow') || line.toLowerCase().includes('graph')) {
return line.toLowerCase().startsWith('graph') ? line : `graph ${line.split(' ')[1] || 'TD'}`
}
return line
}
// 其他行添加缩进
return ` ${line}`
}).join('\n')
}
console.log('Formatted Mermaid content:', formattedContent)
return `<div class="mermaid">\n${formattedContent}\n</div>`
} catch (error: unknown) {
console.error('Error processing Mermaid content:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return `<pre class="mermaid-error">Error rendering diagram: ${errorMessage}</pre>`
}
}
}]
})
} }
public parse(markdown: string): string { public parse(markdown: string): string {

View File

@ -0,0 +1,35 @@
import type { MermaidConfig } from 'mermaid'
export interface MermaidRendererProps {
/** Mermaid 图表的源代码 */
content: string
/** 自定义类名 */
className?: string
/** 自定义配置,会与默认配置合并 */
config?: Partial<MermaidConfig>
/** 渲染完成的回调 */
onRender?: (svg: string) => void
/** 渲染失败的回调 */
onError?: (error: MermaidError) => void
}
export interface MermaidError {
/** 原始内容 */
content: string
/** 错误信息 */
message: string
/** 错误对象 */
error: Error
}
export interface MermaidThemeVariables {
primaryColor: string
primaryBorderColor: string
primaryTextColor: string
lineColor: string
textColor: string
fontSize: string
fontFamily: string
}
export type MermaidTheme = 'default' | 'dark' | 'neutral' | 'forest'

View File

@ -0,0 +1,86 @@
import mermaid from 'mermaid'
import type { MermaidConfig } from 'mermaid'
import type { MermaidError, MermaidTheme } from '../types/mermaid'
import { createMermaidConfig } from '../config/mermaid'
/**
* Mermaid
*/
export const initializeMermaid = (theme: MermaidTheme = 'default', config?: Partial<MermaidConfig>) => {
const defaultConfig = createMermaidConfig(theme)
mermaid.initialize({
...defaultConfig,
...config
})
}
/**
* Mermaid
*/
export const renderMermaidDiagram = async (
content: string,
config?: Partial<MermaidConfig>
): Promise<{ svg: string }> => {
try {
// 先尝试解析,检查语法错误
await mermaid.parse(content)
// 生成唯一 ID
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substring(2)}`
// 渲染图表
return await mermaid.render(id, content)
} catch (error) {
const mermaidError: MermaidError = {
content,
message: error instanceof Error ? error.message : '未知错误',
error: error instanceof Error ? error : new Error('未知错误')
}
throw mermaidError
}
}
/**
* Mermaid
*/
export const renderMermaidDiagrams = async (selector = '.mermaid') => {
try {
const elements = document.querySelectorAll<HTMLElement>(selector)
if (!elements.length) return
for (const element of Array.from(elements)) {
try {
// 如果已经渲染过,跳过
if (element.querySelector('svg')) continue
// 获取图表源码
const source = element.textContent?.trim() || ''
if (!source) continue
// 渲染图表
const { svg } = await renderMermaidDiagram(source)
element.innerHTML = svg
} catch (error) {
console.error('Failed to render mermaid diagram:', error)
const errorMessage = error instanceof Error ? error.message : '图表渲染失败'
const errorContent = element.textContent?.trim() || ''
element.innerHTML = `
<div class="mermaid-error rounded-lg overflow-hidden border border-red-200">
<div class="bg-red-50 p-3 text-red-700 text-sm">${errorMessage}</div>
<pre class="bg-white p-3 m-0 text-sm overflow-x-auto">${errorContent}</pre>
</div>
`
}
}
} catch (error) {
console.error('Failed to initialize mermaid:', error)
}
}
/**
*
*/
export const getCurrentTheme = (): MermaidTheme => {
if (typeof window === 'undefined') return 'default'
return document.documentElement.classList.contains('dark') ? 'dark' : 'default'
}