neurapress/src/components/editor/WechatEditor.tsx
2025-02-02 18:06:01 +08:00

643 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { templates } from '@/config/wechat-templates'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown'
import { useEditorSync } from './hooks/useEditorSync'
import { useAutoSave } from './hooks/useAutoSave'
import { EditorToolbar } from './components/EditorToolbar'
import { EditorPreview } from './components/EditorPreview'
import { MarkdownToolbar } from './components/MarkdownToolbar'
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 { codeThemes, type CodeThemeId } from '@/config/code-themes'
import '@/styles/code-themes.css'
import { copyHandler, initializeMermaid, MERMAID_CONFIG } from './utils/copy-handler'
// 计算阅读时间假设每分钟阅读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 default function WechatEditor() {
const { toast } = useToast()
const editorRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [value, setValue] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<string>('default')
const [showPreview, setShowPreview] = useState(true)
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
const [isConverting, setIsConverting] = 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)
// 使用自定义 hooks
const { handleScroll } = useEditorSync(editorRef)
const { handleEditorChange } = useAutoSave(value, setIsDraft)
// 初始化 Mermaid
useEffect(() => {
const init = async () => {
await initializeMermaid()
}
init()
}, [])
// 处理编辑器输入
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
setValue(newValue)
handleEditorChange(newValue)
// 保存光标位置
setCursorPosition({
start: e.target.selectionStart,
end: e.target.selectionEnd
})
}, [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(() => {
try {
localStorage.setItem('wechat_editor_content', value)
setIsDraft(false)
toast({
title: "保存成功",
description: "内容已保存到本地",
duration: 3000
})
} catch (error) {
toast({
variant: "destructive",
title: "保存失败",
description: "无法保存内容,请检查浏览器存储空间",
action: <ToastAction altText="重试"></ToastAction>,
})
}
}, [value, toast])
// 监听快捷键保存事件
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
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)
toast({
variant: "destructive",
title: "预览更新失败",
description: "生成预览内容时发生错误",
})
} finally {
setIsConverting(false)
}
}
updatePreview()
}, [value, selectedTemplate, styleOptions, codeTheme, getPreviewContent, toast])
// 加载已保存的内容
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="放弃">稿</ToastAction>,
duration: 5000,
})
} else if (savedContent) {
setValue(savedContent)
}
}, [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 }) => {
setValue(article.content)
setSelectedTemplate(article.template)
setIsDraft(false)
toast({
title: "加载成功",
description: "已加载选中的文章",
duration: 2000
})
}, [toast])
// 处理新建文章
const handleNewArticle = useCallback(() => {
if (isDraft) {
toast({
title: "提示",
description: "当前文章未保存,是否继续?",
action: (
<ToastAction altText="继续" onClick={() => {
setValue('# 新文章\n\n开始写作...')
setIsDraft(false)
}}>
</ToastAction>
),
duration: 5000,
})
return
}
setValue('# 新文章\n\n开始写作...')
setIsDraft(false)
}, [isDraft, toast])
// 处理工具栏插入文本
const handleToolbarInsert = useCallback((text: string, options?: { wrap?: boolean; placeholder?: string; suffix?: string }) => {
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = value.substring(start, end)
let newText = ''
let newCursorPos = 0
if (options?.wrap && selectedText) {
// 如果有选中文本且需要包裹
newText = value.substring(0, start) +
text + selectedText + (options.suffix || text) +
value.substring(end)
newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length)
} else {
// 插入新文本
const insertText = selectedText || options?.placeholder || ''
newText = value.substring(0, start) +
text + insertText + (options?.suffix || '') +
value.substring(end)
newCursorPos = start + text.length + insertText.length + (options?.suffix?.length || 0)
}
setValue(newText)
handleEditorChange(newText)
// 恢复焦点并设置光标位置
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(newCursorPos, newCursorPos)
})
}, [value, handleEditorChange])
// 处理模版选择
const handleTemplateSelect = useCallback((templateId: string) => {
setSelectedTemplate(templateId)
// 重置样式设置为模版默认值
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 (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)
// 使用 scrollTo 带平滑滚动效果
previewContainer.scrollTo({
top: targetScrollTop,
behavior: 'instant' // 使用即时滚动而不是平滑滚动
})
} finally {
// 使用较短的延迟时间
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current)
}
scrollTimeout.current = setTimeout(() => {
isScrolling.current = false
}, 50) // 减少延迟时间到 50ms
}
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current)
}
}
}, [])
// 清除编辑器内容
const handleClear = useCallback(() => {
if (window.confirm('确定要清除所有内容吗?')) {
setValue('')
handleEditorChange('')
toast({
title: "已清除",
description: "编辑器内容已清除",
duration: 2000
})
}
}, [handleEditorChange, toast])
// 处理代码主题变化
const handleCodeThemeChange = useCallback((theme: CodeThemeId) => {
setCodeTheme(theme)
// 立即重新生成预览内容
setIsConverting(true)
try {
const content = getPreviewContent()
setPreviewContent(content)
} catch (error) {
console.error('Error updating preview:', error)
toast({
variant: "destructive",
title: "预览更新失败",
description: "生成预览内容时发生错误",
})
} finally {
setIsConverting(false)
}
}, [getPreviewContent, toast])
return (
<div className="h-screen flex flex-col overflow-hidden">
<div className="hidden sm:block">
<EditorToolbar
value={value}
isDraft={isDraft}
showPreview={showPreview}
selectedTemplate={selectedTemplate}
onSave={handleSave}
onCopy={handleCopy}
onCopyPreview={handleCopy}
onNewArticle={handleNewArticle}
onArticleSelect={handleArticleSelect}
onTemplateSelect={handleTemplateSelect}
onTemplateChange={() => setValue(value)}
onStyleOptionsChange={setStyleOptions}
onPreviewToggle={() => setShowPreview(!showPreview)}
styleOptions={styleOptions}
wordCount={wordCount}
readingTime={readingTime}
codeTheme={codeTheme}
onCodeThemeChange={handleCodeThemeChange}
/>
</div>
<div className="flex-1 flex flex-col sm:flex-row overflow-hidden">
{/* Mobile View */}
<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-1 mr-2">
<WechatStylePicker
value={selectedTemplate}
onSelect={handleTemplateSelect}
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleClear}
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-destructive hover:bg-muted transition-colors"
title="清除内容"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleCopy}
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>
</div>
</div>
<Tabs defaultValue="editor" className="flex-1 flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="editor"></TabsTrigger>
<TabsTrigger value="preview"></TabsTrigger>
</TabsList>
<TabsContent value="editor" className="flex-1 data-[state=inactive]:hidden">
<div
ref={editorRef}
className={cn(
"h-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleInput}
onKeyDown={handleKeyDown}
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
placeholder="开始写作..."
spellCheck={false}
onScroll={handleEditorScroll}
/>
</div>
</TabsContent>
<TabsContent value="preview" className="flex-1 data-[state=inactive]:hidden">
<div className="h-full overflow-y-auto">
<EditorPreview
previewRef={previewRef}
selectedTemplate={selectedTemplate}
previewSize={previewSize}
isConverting={isConverting}
previewContent={previewContent}
codeTheme={codeTheme}
onPreviewSizeChange={setPreviewSize}
/>
</div>
</TabsContent>
</Tabs>
</div>
{/* Desktop Split View */}
<div className="hidden sm:flex flex-1 flex-row">
<div
ref={editorRef}
className={cn(
"editor-container bg-background transition-all duration-300 ease-in-out flex flex-col h-full",
showPreview
? "w-1/2 border-r"
: "w-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<MarkdownToolbar onInsert={handleToolbarInsert} />
<div className="flex-1 overflow-hidden">
<textarea
ref={textareaRef}
value={value}
onChange={handleInput}
onKeyDown={handleKeyDown}
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
placeholder="开始写作..."
spellCheck={false}
onScroll={handleEditorScroll}
/>
</div>
</div>
{showPreview && (
<EditorPreview
previewRef={previewRef}
selectedTemplate={selectedTemplate}
previewSize={previewSize}
isConverting={isConverting}
previewContent={previewContent}
codeTheme={codeTheme}
onPreviewSizeChange={setPreviewSize}
/>
)}
</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="flex items-center gap-2 text-sm text-muted-foreground">
<Type className="h-4 w-4" />
<span>{wordCount} </span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mr-4">
<Clock className="h-4 w-4" />
<span> {readingTime}</span>
</div>
</div>
{/* 为底部工具栏添加间距 */}
<div className="h-10" />
</div>
)
}