460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
'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<HTMLDivElement>(null)
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
const previewRef = useRef<HTMLDivElement>(null)
|
|
const [value, setValue] = useState('')
|
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('creative')
|
|
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 })
|
|
|
|
// 使用自定义 hooks
|
|
const { handleScroll } = useEditorSync(editorRef)
|
|
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
|
|
|
// 处理编辑器输入
|
|
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 = {
|
|
...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: <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(() => {
|
|
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: <ToastAction altText="放弃">放弃草稿</ToastAction>,
|
|
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: <ToastAction altText="重试">重试</ToastAction>,
|
|
}))
|
|
}, [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: <ToastAction altText="重试">重试</ToastAction>,
|
|
})
|
|
}
|
|
}
|
|
}, [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: (
|
|
<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])
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<div className="hidden sm:block">
|
|
<EditorToolbar
|
|
value={value}
|
|
isDraft={isDraft}
|
|
showPreview={showPreview}
|
|
selectedTemplate={selectedTemplate}
|
|
onSave={handleSave}
|
|
onCopy={copyContent}
|
|
onCopyPreview={handleCopy}
|
|
onNewArticle={handleNewArticle}
|
|
onArticleSelect={handleArticleSelect}
|
|
onTemplateSelect={setSelectedTemplate}
|
|
onTemplateChange={() => setValue(value)}
|
|
onStyleOptionsChange={setStyleOptions}
|
|
onPreviewToggle={() => setShowPreview(!showPreview)}
|
|
styleOptions={styleOptions}
|
|
/>
|
|
</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={setSelectedTemplate}
|
|
/>
|
|
</div>
|
|
<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>
|
|
<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 overflow-y-auto",
|
|
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"
|
|
placeholder="开始写作..."
|
|
spellCheck={false}
|
|
/>
|
|
</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}
|
|
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",
|
|
showPreview
|
|
? "h-full w-1/2 border-r"
|
|
: "h-full w-full",
|
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
|
)}
|
|
>
|
|
<MarkdownToolbar onInsert={handleToolbarInsert} />
|
|
<div className="flex-1">
|
|
<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"
|
|
placeholder="开始写作..."
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{showPreview && (
|
|
<EditorPreview
|
|
previewRef={previewRef}
|
|
selectedTemplate={selectedTemplate}
|
|
previewSize={previewSize}
|
|
isConverting={isConverting}
|
|
previewContent={previewContent}
|
|
onPreviewSizeChange={setPreviewSize}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |