'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 } from '@/lib/markdown' import { 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 { MobileToolbar } from './components/MobileToolbar' 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 } from 'lucide-react' 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('creative') 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 }) // 使用自定义 hooks const { handleScroll } = useEditorSync(editorRef) const { handleEditorChange } = useAutoSave(value, setIsDraft) // 处理编辑器输入 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 = { ...styleOptions, ...(template?.options || {}) } 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]) // 手动保存 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(() => { if (!showPreview) return setIsConverting(true) const updatePreview = async () => { try { const content = getPreviewContent() setPreviewContent(content) } finally { setIsConverting(false) } } updatePreview() }, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent]) // 加载已保存的内容 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 copyContent = useCallback(() => { const content = getPreviewContent() navigator.clipboard.writeText(content) .then(() => toast({ title: "复制成功", description: "已复制源代码到剪贴板", duration: 2000 })) .catch(err => toast({ variant: "destructive", title: "复制失败", description: "无法访问剪贴板,请检查浏览器权限", action: 重试, })) }, [getPreviewContent, toast]) const handleCopy = useCallback(async () => { const previewContent = previewRef.current?.querySelector('.preview-content') as HTMLElement | null if (!previewContent) { toast({ variant: "destructive", title: "复制失败", description: "未找到预览内容", duration: 2000 }) return } try { const tempDiv = document.createElement('div') tempDiv.innerHTML = previewContent.innerHTML const template = templates.find(t => t.id === selectedTemplate) if (template) { tempDiv.className = template.styles } if (!tempDiv.innerHTML.trim()) { toast({ variant: "destructive", title: "复制失败", description: "预览内容为空", duration: 2000 }) return } const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' }) const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' }) await navigator.clipboard.write([ new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob }) ]) toast({ title: "复制成功", description: template ? "已复制预览内容(包含样式)" : "已复制预览内容", duration: 2000 }) } catch (err) { console.error('Copy error:', err) try { await navigator.clipboard.writeText(previewContent.innerText) toast({ title: "复制成功", description: "已复制预览内容(仅文本)", duration: 2000 }) } catch (fallbackErr) { toast({ variant: "destructive", title: "复制失败", description: "无法访问剪贴板,请检查浏览器权限", action: 重试, }) } } }, [selectedTemplate, toast]) // 检测是否为移动设备 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]) return (
setValue(value)} onStyleOptionsChange={setStyleOptions} onPreviewToggle={() => setShowPreview(!showPreview)} styleOptions={styleOptions} />
{/* Mobile View */}
编辑 预览
t.id === selectedTemplate)?.styles )} >