重构下编辑器

This commit is contained in:
tianyaxiang 2025-01-29 20:36:33 +08:00
parent 660a48118f
commit e211a5b179
7 changed files with 444 additions and 533 deletions

View File

@ -10,34 +10,23 @@ import mermaid from '@bytemd/plugin-mermaid'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { templates } from '@/config/wechat-templates'
import { cn } from '@/lib/utils'
import { Copy, Smartphone, Loader2, Save, Plus } from 'lucide-react'
import { WechatStylePicker } from '../template/WechatStylePicker'
import { StyleConfigDialog } from './StyleConfigDialog'
import { convertToWechat } from '@/lib/markdown'
import { type RendererOptions } from '@/lib/markdown'
import { TemplateManager } from '../template/TemplateManager'
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 { type PreviewSize } from './constants'
import 'bytemd/dist/index.css'
import 'highlight.js/styles/github.css'
import 'katex/dist/katex.css'
import type { BytemdPlugin } from 'bytemd'
import { ArticleList } from './ArticleList'
const PREVIEW_SIZES = {
small: { width: '360px', label: '小屏' },
medium: { width: '390px', label: '中屏' },
large: { width: '420px', label: '大屏' },
full: { width: '100%', label: '全屏' },
} as const
type PreviewSize = keyof typeof PREVIEW_SIZES
const AUTO_SAVE_DELAY = 3000 // 自动保存延迟(毫秒)
export default function WechatEditor() {
const { toast } = useToast()
const autoSaveTimerRef = useRef<NodeJS.Timeout>()
const editorRef = useRef<HTMLDivElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [value, setValue] = useState('')
@ -48,85 +37,9 @@ export default function WechatEditor() {
const [isConverting, setIsConverting] = useState(false)
const [isDraft, setIsDraft] = useState(false)
// 防止滚动事件循环的标志
const isScrolling = useRef(false)
// 同步滚动处理
const handleScroll = useCallback((event: Event) => {
const source = event.target
// 检查是否是编辑器滚动
const isEditor = source instanceof Element && source.closest('.editor-container')
if (!editorRef.current) return
const editorElement = editorRef.current.querySelector('.bytemd-editor')
if (!editorElement) return
// 防止滚动事件循环
if (isScrolling.current) return
isScrolling.current = true
try {
if (isEditor) {
const sourceScrollTop = (source as Element).scrollTop
const sourceMaxScroll = (source as Element).scrollHeight - (source as Element).clientHeight
const percentage = sourceScrollTop / sourceMaxScroll
const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight
window.scrollTo({
top: percentage * windowMaxScroll,
behavior: 'auto'
})
} else {
const windowScrollTop = window.scrollY
const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight
const percentage = windowScrollTop / windowMaxScroll
const targetScrollTop = percentage * (editorElement.scrollHeight - editorElement.clientHeight)
editorElement.scrollTop = targetScrollTop
}
} finally {
// 确保在下一帧重置标志
requestAnimationFrame(() => {
isScrolling.current = false
})
}
}, [])
// 添加滚动事件监听
useEffect(() => {
const editorElement = editorRef.current?.querySelector('.bytemd-editor')
if (editorElement) {
editorElement.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
editorElement.removeEventListener('scroll', handleScroll)
window.removeEventListener('scroll', handleScroll)
}
}
}, [handleScroll])
// 自动保存处理
const handleEditorChange = useCallback((v: string) => {
setValue(v)
setIsDraft(true)
// 清除之前的定时器
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
// 设置新的自动保存定时器
autoSaveTimerRef.current = setTimeout(() => {
localStorage.setItem('wechat_editor_draft', v)
toast({
description: "内容已自动保存",
duration: 2000
})
}, AUTO_SAVE_DELAY)
}, [toast])
// 使用自定义 hooks
const { handleScroll } = useEditorSync(editorRef)
const { handleEditorChange } = useAutoSave(value, setIsDraft)
// 手动保存
const handleSave = useCallback(() => {
@ -166,15 +79,6 @@ export default function WechatEditor() {
}
}, [toast])
// 清理自动保存定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
}
}, [])
// 监听快捷键保存事件
useEffect(() => {
const handleSaveShortcut = (e: CustomEvent<string>) => {
@ -187,7 +91,7 @@ export default function WechatEditor() {
}
}, [handleSave])
const getPreviewContent = () => {
const getPreviewContent = useCallback(() => {
if (!value) return ''
const template = templates.find(t => t.id === selectedTemplate)
@ -212,9 +116,9 @@ export default function WechatEditor() {
console.error('Template transformation error:', error)
return html
}
}
}, [value, selectedTemplate, styleOptions])
const copyContent = () => {
const copyContent = useCallback(() => {
const content = getPreviewContent()
navigator.clipboard.writeText(content)
.then(() => toast({
@ -228,14 +132,9 @@ export default function WechatEditor() {
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
}))
}
}, [getPreviewContent, toast])
const handleTemplateChange = () => {
setValue(value)
}
const handleCopy = async () => {
// 使用 bytemd 编辑器的预览区域
const handleCopy = useCallback(async () => {
const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
if (!previewContent) {
toast({
@ -248,75 +147,14 @@ export default function WechatEditor() {
}
try {
// 创建一个临时容器
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent.innerHTML
// 应用模板样式
const template = templates.find(t => t.id === selectedTemplate)
if (template) {
tempDiv.className = template.styles
}
// 处理图片
const images = tempDiv.querySelectorAll('img')
images.forEach(img => {
// 确保图片使用绝对路径
if (img.src.startsWith('/')) {
img.src = window.location.origin + img.src
}
})
// 处理代码块
const codeBlocks = tempDiv.querySelectorAll('pre code')
codeBlocks.forEach(code => {
if (code.parentElement) {
code.parentElement.style.whiteSpace = 'pre-wrap'
code.parentElement.style.wordWrap = 'break-word'
}
})
// 应用样式选项
const mergedOptions = {
...styleOptions,
...(template?.options || {})
}
// 应用标题样式
const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6')
headings.forEach(heading => {
const level = heading.tagName.toLowerCase()
const style = mergedOptions.block?.[level as keyof RendererOptions['block']]
if (style && heading instanceof HTMLElement) {
Object.assign(heading.style, style)
}
})
// 应用段落样式
const paragraphs = tempDiv.querySelectorAll('p')
paragraphs.forEach(p => {
if (mergedOptions.block?.p && p instanceof HTMLElement) {
Object.assign(p.style, mergedOptions.block.p)
}
})
// 应用引用样式
const blockquotes = tempDiv.querySelectorAll('blockquote')
blockquotes.forEach(quote => {
if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) {
Object.assign(quote.style, mergedOptions.block.blockquote)
}
})
// 应用行内代码样式
const inlineCodes = tempDiv.querySelectorAll(':not(pre) > code')
inlineCodes.forEach(code => {
if (mergedOptions.inline?.codespan && code instanceof HTMLElement) {
Object.assign(code.style, mergedOptions.inline.codespan)
}
})
// 使用 Clipboard API 复制
const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' })
@ -336,94 +174,17 @@ export default function WechatEditor() {
})
} catch (err) {
console.error('Copy error:', err)
// 降级处理:尝试使用 execCommand
try {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent.innerHTML
document.body.appendChild(tempDiv)
const range = document.createRange()
range.selectNode(tempDiv)
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(range)
document.execCommand('copy')
selection.removeAllRanges()
}
document.body.removeChild(tempDiv)
toast({
title: "复制成功",
description: "已复制预览内容(兼容模式)",
duration: 2000
})
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
}
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
}
}
}, [selectedTemplate, toast])
// 创建编辑器插件
const createEditorPlugin = useCallback((): BytemdPlugin => {
const applyTemplateStyles = (markdownBody: Element, selectedTemplateId: string, options: RendererOptions) => {
const template = templates.find(t => t.id === selectedTemplateId)
if (template) {
markdownBody.classList.add(template.styles)
// 应用模板的样式配置
const mergedOptions = {
...options,
...(template.options || {})
}
// 应用标题样式
const headings = markdownBody.querySelectorAll('h1, h2, h3, h4, h5, h6')
headings.forEach(heading => {
const level = heading.tagName.toLowerCase()
const style = mergedOptions.block?.[level as keyof RendererOptions['block']]
if (style && heading instanceof HTMLElement) {
Object.assign(heading.style, style)
}
})
// 应用段落样式
const paragraphs = markdownBody.querySelectorAll('p')
paragraphs.forEach(p => {
if (mergedOptions.block?.p && p instanceof HTMLElement) {
Object.assign(p.style, mergedOptions.block.p)
}
})
// 应用引用样式
const blockquotes = markdownBody.querySelectorAll('blockquote')
blockquotes.forEach(quote => {
if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) {
Object.assign(quote.style, mergedOptions.block.blockquote)
}
})
// 应用代码块样式
const codeBlocks = markdownBody.querySelectorAll('pre code')
codeBlocks.forEach(code => {
if (mergedOptions.block?.code_pre && code.parentElement instanceof HTMLElement) {
Object.assign(code.parentElement.style, mergedOptions.block.code_pre)
}
})
// 应用行内代码样式
const inlineCodes = markdownBody.querySelectorAll(':not(pre) > code')
inlineCodes.forEach(code => {
if (mergedOptions.inline?.codespan && code instanceof HTMLElement) {
Object.assign(code.style, mergedOptions.inline.codespan)
}
})
}
}
return {
actions: [
{
@ -436,115 +197,26 @@ export default function WechatEditor() {
window.dispatchEvent(event)
}
}
},
{
title: '复制预览内容',
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>',
handler: {
type: 'action',
click: handleCopy
}
},
{
title: '复制源码',
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>',
handler: {
type: 'action',
click: copyContent
}
},
{
title: '预览尺寸',
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 3H3v18h18V3z"></path><path d="M21 13H3"></path><path d="M12 3v18"></path></svg>',
handler: {
type: 'dropdown',
actions: Object.entries(PREVIEW_SIZES).map(([key, { label }]) => ({
title: label,
handler: {
type: 'action',
click: () => {
setPreviewSize(key as PreviewSize)
const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
if (previewContent) {
const container = previewContent.parentElement
if (container) {
const size = PREVIEW_SIZES[key as PreviewSize]
container.style.maxWidth = size.width
container.style.margin = '0 auto'
container.style.transition = 'max-width 0.3s ease'
container.style.textAlign = 'center'
}
}
}
}
}))
}
}
],
viewerEffect({ markdownBody }) {
// 图片加载优化
const images = markdownBody.querySelectorAll('img')
images.forEach(img => {
if (img instanceof HTMLImageElement) {
img.style.opacity = '0'
img.style.transition = 'opacity 0.3s ease'
img.onload = () => img.style.opacity = '1'
img.onerror = () => {
img.style.opacity = '1'
img.style.filter = 'grayscale(1)'
img.title = '图片加载失败'
}
}
})
// 链接优化
const links = markdownBody.querySelectorAll('a')
links.forEach(link => {
if (link instanceof HTMLAnchorElement) {
link.target = '_blank'
link.rel = 'noopener noreferrer'
}
})
// 应用模板样式
if (selectedTemplate) {
applyTemplateStyles(markdownBody, selectedTemplate, styleOptions)
}
// 设置预览容器宽度
const container = markdownBody.parentElement
if (container) {
const size = PREVIEW_SIZES[previewSize]
container.style.maxWidth = size.width
container.style.margin = '0 auto'
container.style.transition = 'max-width 0.3s ease'
}
}
]
}
}, [selectedTemplate, styleOptions, handleCopy, copyContent, previewSize, setPreviewSize, editorRef])
}, [])
// 使用创建的插件
const plugins = useMemo(() => [
gfm(), // 使用默认配置
gfm(),
breaks(),
frontmatter(),
math({
// 配置数学公式渲染
katexOptions: {
throwOnError: false,
output: 'html'
}
}),
mermaid({
// 配置 Mermaid 图表渲染
theme: 'default'
}),
highlight({
// 配置代码高亮
init: (hljs) => {
// 可以在这里注册额外的语言
}
}),
highlight(),
createEditorPlugin()
], [createEditorPlugin])
@ -580,7 +252,6 @@ export default function WechatEditor() {
// 处理新建文章
const handleNewArticle = useCallback(() => {
// 如果有未保存的内容,提示用户
if (isDraft) {
toast({
title: "提示",
@ -598,90 +269,28 @@ export default function WechatEditor() {
return
}
// 直接新建
setValue('# 新文章\n\n开始写作...')
setIsDraft(false)
}, [isDraft, toast])
return (
<div className="h-full flex flex-col">
<div className="flex-none border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-20">
<div className="container mx-auto">
<div className="p-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4 w-full sm:w-auto">
<ArticleList
onSelect={handleArticleSelect}
currentContent={value}
onNew={handleNewArticle}
/>
<button
onClick={handleNewArticle}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors w-full sm:w-auto justify-center bg-muted text-muted-foreground hover:bg-muted/90"
>
<Plus className="h-4 w-4" />
</button>
<WechatStylePicker
value={selectedTemplate}
onSelect={setSelectedTemplate}
/>
<div className="hidden sm:block h-6 w-px bg-border" />
<TemplateManager onTemplateChange={handleTemplateChange} />
<div className="hidden sm:block h-6 w-px bg-border" />
<StyleConfigDialog
value={styleOptions}
onChange={setStyleOptions}
/>
<div className="hidden sm:block h-6 w-px bg-border" />
<button
onClick={() => setShowPreview(!showPreview)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors w-full sm:w-auto justify-center",
showPreview
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90"
)}
>
<Smartphone className="h-4 w-4" />
{showPreview ? '编辑' : '预览'}
</button>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
{isDraft && (
<span className="text-sm text-muted-foreground"></span>
)}
<button
onClick={handleSave}
className={cn(
"inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors flex-1 sm:flex-none",
isDraft
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90"
)}
>
<Save className="h-4 w-4" />
<span></span>
</button>
<button
onClick={copyContent}
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-muted text-muted-foreground hover:bg-muted/90 text-sm transition-colors flex-1 sm:flex-none"
>
<Copy className="h-4 w-4" />
<span></span>
</button>
<button
onClick={handleCopy}
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm transition-colors flex-1 sm:flex-none"
>
<Copy className="h-4 w-4" />
<span></span>
</button>
</div>
</div>
</div>
</div>
</div>
<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 className="flex-1 flex flex-col sm:flex-row overflow-hidden">
<div
@ -711,108 +320,25 @@ export default function WechatEditor() {
</div>
{showPreview && (
<div
ref={previewRef}
className={cn(
"preview-container bg-background transition-all duration-300 ease-in-out flex flex-col",
"h-[50%] sm:h-full sm:w-1/2",
"markdown-body",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-4 py-2 flex items-center justify-between z-10 sticky top-0">
<div className="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2">
<select
value={previewSize}
onChange={(e) => setPreviewSize(e.target.value as PreviewSize)}
className="text-sm border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
>
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-h-full py-8 px-4">
<div
className={cn(
"bg-background mx-auto rounded-lg transition-all duration-300",
previewSize === 'full' ? '' : 'border shadow-sm'
)}
style={{
width: PREVIEW_SIZES[previewSize].width,
maxWidth: '100%'
}}
>
{isConverting ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className={cn(
"preview-content py-4",
"prose prose-slate dark:prose-invert max-w-none",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="px-6" dangerouslySetInnerHTML={{ __html: getPreviewContent() }} />
</div>
)}
</div>
</div>
</div>
</div>
<EditorPreview
previewRef={previewRef}
selectedTemplate={selectedTemplate}
previewSize={previewSize}
isConverting={isConverting}
previewContent={getPreviewContent()}
onPreviewSizeChange={setPreviewSize}
/>
)}
</div>
{/* 移动端底部工具栏 */}
<div className="sm:hidden fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t p-2 flex justify-around">
<button
onClick={() => setShowPreview(!showPreview)}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
showPreview
? "text-primary"
: "text-muted-foreground"
)}
>
<Smartphone className="h-5 w-5" />
{showPreview ? '编辑' : '预览'}
</button>
<button
onClick={handleSave}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
isDraft
? "text-primary"
: "text-muted-foreground"
)}
>
<Save className="h-5 w-5" />
{isDraft && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />}
</button>
<button
onClick={copyContent}
className="flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs text-muted-foreground transition-colors relative"
>
<Copy className="h-5 w-5" />
</button>
<button
onClick={handleCopy}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
"hover:bg-primary/10 active:bg-primary/20",
showPreview ? "text-primary" : "text-muted-foreground"
)}
>
<Copy className="h-5 w-5" />
</button>
</div>
<MobileToolbar
showPreview={showPreview}
isDraft={isDraft}
onPreviewToggle={() => setShowPreview(!showPreview)}
onSave={handleSave}
onCopy={copyContent}
onCopyPreview={handleCopy}
/>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { cn } from '@/lib/utils'
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
import { Loader2 } from 'lucide-react'
import { templates } from '@/config/wechat-templates'
interface EditorPreviewProps {
previewRef: React.RefObject<HTMLDivElement>
selectedTemplate: string
previewSize: PreviewSize
isConverting: boolean
previewContent: string
onPreviewSizeChange: (size: PreviewSize) => void
}
export function EditorPreview({
previewRef,
selectedTemplate,
previewSize,
isConverting,
previewContent,
onPreviewSizeChange
}: EditorPreviewProps) {
return (
<div
ref={previewRef}
className={cn(
"preview-container bg-background transition-all duration-300 ease-in-out flex flex-col",
"h-[50%] sm:h-full sm:w-1/2",
"markdown-body",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-4 py-2 flex items-center justify-between z-10 sticky top-0">
<div className="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2">
<select
value={previewSize}
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)}
className="text-sm border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
>
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-h-full py-8 px-4">
<div
className={cn(
"bg-background mx-auto rounded-lg transition-all duration-300",
previewSize === 'full' ? '' : 'border shadow-sm'
)}
style={{
width: PREVIEW_SIZES[previewSize].width,
maxWidth: '100%'
}}
>
{isConverting ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className={cn(
"preview-content py-4",
"prose prose-slate dark:prose-invert max-w-none",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="px-6" dangerouslySetInnerHTML={{ __html: previewContent }} />
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,122 @@
import { Copy, Plus, Save, Smartphone } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WechatStylePicker } from '../../template/WechatStylePicker'
import { TemplateManager } from '../../template/TemplateManager'
import { StyleConfigDialog } from '../StyleConfigDialog'
import { ArticleList } from '../ArticleList'
import { type Article } from '../constants'
import { type RendererOptions } from '@/lib/markdown'
interface EditorToolbarProps {
value: string
isDraft: boolean
showPreview: boolean
selectedTemplate: string
onSave: () => void
onCopy: () => void
onCopyPreview: () => void
onNewArticle: () => void
onArticleSelect: (article: Article) => void
onTemplateSelect: (template: string) => void
onTemplateChange: () => void
onStyleOptionsChange: (options: RendererOptions) => void
onPreviewToggle: () => void
styleOptions: RendererOptions
}
export function EditorToolbar({
value,
isDraft,
showPreview,
selectedTemplate,
onSave,
onCopy,
onCopyPreview,
onNewArticle,
onArticleSelect,
onTemplateSelect,
onTemplateChange,
onStyleOptionsChange,
onPreviewToggle,
styleOptions
}: EditorToolbarProps) {
return (
<div className="flex-none border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-20">
<div className="container mx-auto">
<div className="p-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4 w-full sm:w-auto">
<ArticleList
onSelect={onArticleSelect}
currentContent={value}
onNew={onNewArticle}
/>
<button
onClick={onNewArticle}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors w-full sm:w-auto justify-center bg-muted text-muted-foreground hover:bg-muted/90"
>
<Plus className="h-4 w-4" />
</button>
<WechatStylePicker
value={selectedTemplate}
onSelect={onTemplateSelect}
/>
<div className="hidden sm:block h-6 w-px bg-border" />
<TemplateManager onTemplateChange={onTemplateChange} />
<div className="hidden sm:block h-6 w-px bg-border" />
<StyleConfigDialog
value={styleOptions}
onChange={onStyleOptionsChange}
/>
<div className="hidden sm:block h-6 w-px bg-border" />
<button
onClick={onPreviewToggle}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors w-full sm:w-auto justify-center",
showPreview
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90"
)}
>
<Smartphone className="h-4 w-4" />
{showPreview ? '编辑' : '预览'}
</button>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
{isDraft && (
<span className="text-sm text-muted-foreground"></span>
)}
<button
onClick={onSave}
className={cn(
"inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors flex-1 sm:flex-none",
isDraft
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90"
)}
>
<Save className="h-4 w-4" />
<span></span>
</button>
<button
onClick={onCopy}
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-muted text-muted-foreground hover:bg-muted/90 text-sm transition-colors flex-1 sm:flex-none"
>
<Copy className="h-4 w-4" />
<span></span>
</button>
<button
onClick={onCopyPreview}
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm transition-colors flex-1 sm:flex-none"
>
<Copy className="h-4 w-4" />
<span></span>
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,68 @@
import { Copy, Save, Smartphone } from 'lucide-react'
import { cn } from '@/lib/utils'
interface MobileToolbarProps {
showPreview: boolean
isDraft: boolean
onPreviewToggle: () => void
onSave: () => void
onCopy: () => void
onCopyPreview: () => void
}
export function MobileToolbar({
showPreview,
isDraft,
onPreviewToggle,
onSave,
onCopy,
onCopyPreview
}: MobileToolbarProps) {
return (
<div className="sm:hidden fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t p-2 flex justify-around">
<button
onClick={onPreviewToggle}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
showPreview
? "text-primary"
: "text-muted-foreground"
)}
>
<Smartphone className="h-5 w-5" />
{showPreview ? '编辑' : '预览'}
</button>
<button
onClick={onSave}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
isDraft
? "text-primary"
: "text-muted-foreground"
)}
>
<Save className="h-5 w-5" />
{isDraft && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />}
</button>
<button
onClick={onCopy}
className="flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs text-muted-foreground transition-colors relative"
>
<Copy className="h-5 w-5" />
</button>
<button
onClick={onCopyPreview}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
"hover:bg-primary/10 active:bg-primary/20",
showPreview ? "text-primary" : "text-muted-foreground"
)}
>
<Copy className="h-5 w-5" />
</button>
</div>
)
}

View File

@ -0,0 +1,15 @@
export const PREVIEW_SIZES = {
small: { width: '360px', label: '小屏' },
medium: { width: '390px', label: '中屏' },
large: { width: '420px', label: '大屏' },
full: { width: '100%', label: '全屏' },
} as const
export type PreviewSize = keyof typeof PREVIEW_SIZES
export const AUTO_SAVE_DELAY = 3000 // 自动保存延迟(毫秒)
export interface Article {
content: string
template: string
}

View File

@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef } from 'react'
import { useToast } from '@/components/ui/use-toast'
import { AUTO_SAVE_DELAY } from '../constants'
export function useAutoSave(value: string, setIsDraft: (isDraft: boolean) => void) {
const { toast } = useToast()
const autoSaveTimerRef = useRef<NodeJS.Timeout>()
const handleEditorChange = useCallback((v: string) => {
setIsDraft(true)
// 清除之前的定时器
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
// 设置新的自动保存定时器
autoSaveTimerRef.current = setTimeout(() => {
localStorage.setItem('wechat_editor_draft', v)
toast({
description: "内容已自动保存",
duration: 2000
})
}, AUTO_SAVE_DELAY)
}, [toast, setIsDraft])
// 清理自动保存定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
}
}, [])
return { handleEditorChange }
}

View File

@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef } from 'react'
export function useEditorSync(editorRef: React.RefObject<HTMLDivElement>) {
// 防止滚动事件循环的标志
const isScrolling = useRef(false)
// 同步滚动处理
const handleScroll = useCallback((event: Event) => {
const source = event.target
// 检查是否是编辑器滚动
const isEditor = source instanceof Element && source.closest('.editor-container')
if (!editorRef.current) return
const editorElement = editorRef.current.querySelector('.bytemd-editor')
if (!editorElement) return
// 防止滚动事件循环
if (isScrolling.current) return
isScrolling.current = true
try {
if (isEditor) {
const sourceScrollTop = (source as Element).scrollTop
const sourceMaxScroll = (source as Element).scrollHeight - (source as Element).clientHeight
const percentage = sourceScrollTop / sourceMaxScroll
const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight
window.scrollTo({
top: percentage * windowMaxScroll,
behavior: 'auto'
})
} else {
const windowScrollTop = window.scrollY
const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight
const percentage = windowScrollTop / windowMaxScroll
const targetScrollTop = percentage * (editorElement.scrollHeight - editorElement.clientHeight)
editorElement.scrollTop = targetScrollTop
}
} finally {
// 确保在下一帧重置标志
requestAnimationFrame(() => {
isScrolling.current = false
})
}
}, [editorRef])
// 添加滚动事件监听
useEffect(() => {
const editorElement = editorRef.current?.querySelector('.bytemd-editor')
if (editorElement) {
editorElement.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
editorElement.removeEventListener('scroll', handleScroll)
window.removeEventListener('scroll', handleScroll)
}
}
}, [handleScroll])
return { handleScroll }
}