重构下编辑器
This commit is contained in:
parent
660a48118f
commit
e211a5b179
@ -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>
|
||||
)
|
||||
}
|
78
src/components/editor/components/EditorPreview.tsx
Normal file
78
src/components/editor/components/EditorPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
122
src/components/editor/components/EditorToolbar.tsx
Normal file
122
src/components/editor/components/EditorToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
68
src/components/editor/components/MobileToolbar.tsx
Normal file
68
src/components/editor/components/MobileToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
15
src/components/editor/constants.ts
Normal file
15
src/components/editor/constants.ts
Normal 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
|
||||
}
|
37
src/components/editor/hooks/useAutoSave.ts
Normal file
37
src/components/editor/hooks/useAutoSave.ts
Normal 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 }
|
||||
}
|
65
src/components/editor/hooks/useEditorSync.ts
Normal file
65
src/components/editor/hooks/useEditorSync.ts
Normal 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 }
|
||||
}
|
Loading…
Reference in New Issue
Block a user