643 lines
22 KiB
TypeScript
643 lines
22 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, 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>
|
||
)
|
||
} |