'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(null) const textareaRef = useRef(null) const previewRef = useRef(null) const [value, setValue] = useState('') const [selectedTemplate, setSelectedTemplate] = useState('default') const [showPreview, setShowPreview] = useState(true) const [styleOptions, setStyleOptions] = useState({}) const [previewSize, setPreviewSize] = useState('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('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) => { 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) => { 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: 重试, }) } }, [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: 放弃草稿, duration: 5000, }) } else if (savedContent) { setValue(savedContent) } }, [toast]) // 渲染预览内容 const renderPreview = useCallback(() => { return (
) }, [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: ( { setValue('# 新文章\n\n开始写作...') setIsDraft(false) }}> 继续 ), 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(false) const scrollTimeout = useRef() const lastScrollTop = useRef(0) // 处理滚动同步 const handleEditorScroll = useCallback((e: React.UIEvent) => { 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 (
setValue(value)} onStyleOptionsChange={setStyleOptions} onPreviewToggle={() => setShowPreview(!showPreview)} styleOptions={styleOptions} wordCount={wordCount} readingTime={readingTime} codeTheme={codeTheme} onCodeThemeChange={handleCodeThemeChange} />
{/* Mobile View */}
编辑 预览
t.id === selectedTemplate)?.styles )} >