重构代码
This commit is contained in:
parent
764add8de7
commit
af36766729
@ -30,6 +30,7 @@
|
|||||||
"@types/marked": "^6.0.0",
|
"@types/marked": "^6.0.0",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clipboard-polyfill": "^4.1.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
@ -71,6 +71,9 @@ importers:
|
|||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
clipboard-polyfill:
|
||||||
|
specifier: ^4.1.1
|
||||||
|
version: 4.1.1
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@ -1110,6 +1113,9 @@ packages:
|
|||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clipboard-polyfill@4.1.1:
|
||||||
|
resolution: {integrity: sha512-nbvNLrcX0zviek5QHLFRAaLrx8y/s8+RF2stH43tuS+kP5XlHMrcD0UGBWq43Hwp6WuuK7KefRMP56S45ibZkA==}
|
||||||
|
|
||||||
clone@1.0.4:
|
clone@1.0.4:
|
||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@ -3229,6 +3235,8 @@ snapshots:
|
|||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clipboard-polyfill@4.1.1: {}
|
||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
@ -1,217 +1,45 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { templates } from '@/config/wechat-templates'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown'
|
import { type RendererOptions } from '@/lib/markdown'
|
||||||
import { useEditorSync } from './hooks/useEditorSync'
|
import { useEditorSync } from './hooks/useEditorSync'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { EditorToolbar } from './components/EditorToolbar'
|
import { EditorToolbar } from './components/EditorToolbar'
|
||||||
import { EditorPreview } from './components/EditorPreview'
|
import { EditorPreview } from './components/EditorPreview'
|
||||||
import { MarkdownToolbar } from './components/MarkdownToolbar'
|
import { MarkdownToolbar } from './components/MarkdownToolbar'
|
||||||
import { type PreviewSize } from './constants'
|
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 { useLocalStorage } from '@/hooks/use-local-storage'
|
||||||
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
import '@/styles/code-themes.css'
|
import '@/styles/code-themes.css'
|
||||||
import { copyHandler, initializeMermaid, MERMAID_CONFIG } from './utils/copy-handler'
|
import { templates } from '@/config/wechat-templates'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
// 计算阅读时间(假设每分钟阅读300字)
|
import { usePreviewContent } from './hooks/usePreviewContent'
|
||||||
const calculateReadingTime = (text: string): string => {
|
import { useEditorKeyboard } from './hooks/useEditorKeyboard'
|
||||||
const words = text.trim().length
|
import { useScrollSync } from './hooks/useScrollSync'
|
||||||
const minutes = Math.ceil(words / 300)
|
import { useWordStats } from './hooks/useWordStats'
|
||||||
return `${minutes} 分钟`
|
import { useCopy } from './hooks/useCopy'
|
||||||
}
|
|
||||||
|
|
||||||
// 计算字数
|
|
||||||
const calculateWordCount = (text: string): string => {
|
|
||||||
const count = text.trim().length
|
|
||||||
return count.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WechatEditor() {
|
export default function WechatEditor() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const editorRef = useRef<HTMLDivElement>(null)
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('default')
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('default')
|
||||||
const [showPreview, setShowPreview] = useState(true)
|
const [showPreview, setShowPreview] = useState(true)
|
||||||
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
|
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
|
||||||
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
|
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
|
||||||
const [isConverting, setIsConverting] = useState(false)
|
|
||||||
const [isDraft, setIsDraft] = 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)
|
const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
|
||||||
|
|
||||||
// 使用自定义 hooks
|
// 使用自定义 hooks
|
||||||
const { handleScroll } = useEditorSync(editorRef)
|
const { handleScroll } = useEditorSync(editorRef)
|
||||||
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
||||||
|
const { handleEditorScroll } = useScrollSync()
|
||||||
// 初始化 Mermaid
|
|
||||||
useEffect(() => {
|
|
||||||
const init = async () => {
|
|
||||||
await initializeMermaid()
|
|
||||||
}
|
|
||||||
init()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 处理编辑器输入
|
|
||||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newValue = e.target.value
|
|
||||||
const currentPosition = {
|
|
||||||
start: e.target.selectionStart,
|
|
||||||
end: e.target.selectionEnd,
|
|
||||||
scrollTop: e.target.scrollTop // 保存当前滚动位置
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue(newValue)
|
|
||||||
handleEditorChange(newValue)
|
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 确保在下一帧恢复位置
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.scrollTop = currentPosition.scrollTop // 恢复滚动位置
|
|
||||||
textareaRef.current.setSelectionRange(currentPosition.start, currentPosition.end)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [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(() => {
|
const handleSave = useCallback(() => {
|
||||||
@ -233,78 +61,53 @@ export default function WechatEditor() {
|
|||||||
}
|
}
|
||||||
}, [value, toast])
|
}, [value, toast])
|
||||||
|
|
||||||
// 监听快捷键保存事件
|
const { isConverting, previewContent } = usePreviewContent({
|
||||||
useEffect(() => {
|
value,
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
selectedTemplate,
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
styleOptions,
|
||||||
e.preventDefault()
|
codeTheme
|
||||||
handleSave()
|
})
|
||||||
}
|
|
||||||
|
const { handleKeyDown } = useEditorKeyboard({
|
||||||
|
value,
|
||||||
|
onChange: (newValue) => {
|
||||||
|
setValue(newValue)
|
||||||
|
handleEditorChange(newValue)
|
||||||
|
},
|
||||||
|
onSave: handleSave
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理编辑器输入
|
||||||
|
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value
|
||||||
|
const currentPosition = {
|
||||||
|
start: e.target.selectionStart,
|
||||||
|
end: e.target.selectionEnd,
|
||||||
|
scrollTop: e.target.scrollTop
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
setValue(newValue)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
handleEditorChange(newValue)
|
||||||
}, [handleSave])
|
|
||||||
|
|
||||||
// 更新预览内容
|
requestAnimationFrame(() => {
|
||||||
useEffect(() => {
|
if (textareaRef.current) {
|
||||||
const updatePreview = async () => {
|
textareaRef.current.scrollTop = currentPosition.scrollTop
|
||||||
if (!value) {
|
textareaRef.current.setSelectionRange(currentPosition.start, currentPosition.end)
|
||||||
setPreviewContent('')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}, [handleEditorChange])
|
||||||
|
|
||||||
setIsConverting(true)
|
const { handleCopy } = useCopy()
|
||||||
try {
|
|
||||||
const content = getPreviewContent()
|
|
||||||
setPreviewContent(content)
|
|
||||||
|
|
||||||
// 等待下一个渲染周期,确保内容已更新到 DOM
|
// 处理复制
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
const onCopy = useCallback(async () => {
|
||||||
// 重新初始化 Mermaid,但不阻塞界面
|
return handleCopy(window.getSelection(), previewContent)
|
||||||
initializeMermaid().catch(error => {
|
}, [handleCopy, previewContent])
|
||||||
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="放弃" onClick={handleDiscardDraft}>放弃草稿</ToastAction>,
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
} else if (savedContent) {
|
|
||||||
setValue(savedContent)
|
|
||||||
}
|
|
||||||
}, [toast])
|
|
||||||
|
|
||||||
// 处理放弃草稿
|
// 处理放弃草稿
|
||||||
const handleDiscardDraft = useCallback(() => {
|
const handleDiscardDraft = useCallback(() => {
|
||||||
const savedContent = localStorage.getItem('wechat_editor_content')
|
const savedContent = localStorage.getItem('wechat_editor_content')
|
||||||
// 移除草稿
|
|
||||||
localStorage.removeItem('wechat_editor_draft')
|
localStorage.removeItem('wechat_editor_draft')
|
||||||
// 恢复到最后保存的内容,如果没有则清空
|
|
||||||
setValue(savedContent || '')
|
setValue(savedContent || '')
|
||||||
setIsDraft(false)
|
setIsDraft(false)
|
||||||
toast({
|
toast({
|
||||||
@ -314,35 +117,6 @@ export default function WechatEditor() {
|
|||||||
})
|
})
|
||||||
}, [toast])
|
}, [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 }) => {
|
const handleArticleSelect = useCallback((article: { content: string, template: string }) => {
|
||||||
setValue(article.content)
|
setValue(article.content)
|
||||||
@ -391,13 +165,11 @@ export default function WechatEditor() {
|
|||||||
let newCursorPos = 0
|
let newCursorPos = 0
|
||||||
|
|
||||||
if (options?.wrap && selectedText) {
|
if (options?.wrap && selectedText) {
|
||||||
// 如果有选中文本且需要包裹
|
|
||||||
newText = value.substring(0, start) +
|
newText = value.substring(0, start) +
|
||||||
text + selectedText + (options.suffix || text) +
|
text + selectedText + (options.suffix || text) +
|
||||||
value.substring(end)
|
value.substring(end)
|
||||||
newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length)
|
newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length)
|
||||||
} else {
|
} else {
|
||||||
// 插入新文本
|
|
||||||
const insertText = selectedText || options?.placeholder || ''
|
const insertText = selectedText || options?.placeholder || ''
|
||||||
newText = value.substring(0, start) +
|
newText = value.substring(0, start) +
|
||||||
text + insertText + (options?.suffix || '') +
|
text + insertText + (options?.suffix || '') +
|
||||||
@ -408,7 +180,6 @@ export default function WechatEditor() {
|
|||||||
setValue(newText)
|
setValue(newText)
|
||||||
handleEditorChange(newText)
|
handleEditorChange(newText)
|
||||||
|
|
||||||
// 恢复焦点并设置光标位置
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
@ -418,71 +189,9 @@ export default function WechatEditor() {
|
|||||||
// 处理模版选择
|
// 处理模版选择
|
||||||
const handleTemplateSelect = useCallback((templateId: string) => {
|
const handleTemplateSelect = useCallback((templateId: string) => {
|
||||||
setSelectedTemplate(templateId)
|
setSelectedTemplate(templateId)
|
||||||
// 重置样式设置为模版默认值
|
|
||||||
setStyleOptions({})
|
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 (e.currentTarget.selectionStart !== e.currentTarget.selectionEnd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
previewContainer.scrollTo({
|
|
||||||
top: targetScrollTop,
|
|
||||||
behavior: 'instant'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
if (scrollTimeout.current) {
|
|
||||||
clearTimeout(scrollTimeout.current)
|
|
||||||
}
|
|
||||||
scrollTimeout.current = setTimeout(() => {
|
|
||||||
isScrolling.current = false
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (scrollTimeout.current) {
|
|
||||||
clearTimeout(scrollTimeout.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 清除编辑器内容
|
// 清除编辑器内容
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
if (window.confirm('确定要清除所有内容吗?')) {
|
if (window.confirm('确定要清除所有内容吗?')) {
|
||||||
@ -496,25 +205,44 @@ export default function WechatEditor() {
|
|||||||
}
|
}
|
||||||
}, [handleEditorChange, toast])
|
}, [handleEditorChange, toast])
|
||||||
|
|
||||||
// 处理代码主题变化
|
// 检测是否为移动设备
|
||||||
const handleCodeThemeChange = useCallback((theme: CodeThemeId) => {
|
const isMobile = useCallback(() => {
|
||||||
setCodeTheme(theme)
|
if (typeof window === 'undefined') return false
|
||||||
// 立即重新生成预览内容
|
return window.innerWidth < 640
|
||||||
setIsConverting(true)
|
}, [])
|
||||||
try {
|
|
||||||
const content = getPreviewContent()
|
// 自动切换预览模式
|
||||||
setPreviewContent(content)
|
useEffect(() => {
|
||||||
} catch (error) {
|
const handleResize = () => {
|
||||||
console.error('Error updating preview:', error)
|
if (isMobile()) {
|
||||||
toast({
|
setPreviewSize('full')
|
||||||
variant: "destructive",
|
}
|
||||||
title: "预览更新失败",
|
|
||||||
description: "生成预览内容时发生错误",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsConverting(false)
|
|
||||||
}
|
}
|
||||||
}, [getPreviewContent, toast])
|
|
||||||
|
handleResize()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
// 加载已保存的内容
|
||||||
|
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="放弃" onClick={handleDiscardDraft}>放弃草稿</ToastAction>,
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
} else if (savedContent) {
|
||||||
|
setValue(savedContent)
|
||||||
|
}
|
||||||
|
}, [toast, handleDiscardDraft])
|
||||||
|
|
||||||
|
const { wordCount, readingTime } = useWordStats(value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col overflow-hidden">
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
@ -525,8 +253,8 @@ export default function WechatEditor() {
|
|||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
selectedTemplate={selectedTemplate}
|
selectedTemplate={selectedTemplate}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCopy={handleCopy}
|
onCopy={onCopy}
|
||||||
onCopyPreview={handleCopy}
|
onCopyPreview={onCopy}
|
||||||
onNewArticle={handleNewArticle}
|
onNewArticle={handleNewArticle}
|
||||||
onArticleSelect={handleArticleSelect}
|
onArticleSelect={handleArticleSelect}
|
||||||
onTemplateSelect={handleTemplateSelect}
|
onTemplateSelect={handleTemplateSelect}
|
||||||
@ -537,7 +265,7 @@ export default function WechatEditor() {
|
|||||||
wordCount={wordCount}
|
wordCount={wordCount}
|
||||||
readingTime={readingTime}
|
readingTime={readingTime}
|
||||||
codeTheme={codeTheme}
|
codeTheme={codeTheme}
|
||||||
onCodeThemeChange={handleCodeThemeChange}
|
onCodeThemeChange={setCodeTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -546,10 +274,17 @@ export default function WechatEditor() {
|
|||||||
<div className="sm:hidden flex-1 flex flex-col">
|
<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 items-center justify-between p-2 border-b bg-background">
|
||||||
<div className="flex-1 mr-2">
|
<div className="flex-1 mr-2">
|
||||||
<WechatStylePicker
|
<select
|
||||||
value={selectedTemplate}
|
value={selectedTemplate}
|
||||||
onSelect={handleTemplateSelect}
|
onChange={(e) => handleTemplateSelect(e.target.value)}
|
||||||
/>
|
className="w-full p-2 rounded-md border"
|
||||||
|
>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@ -557,24 +292,18 @@ export default function WechatEditor() {
|
|||||||
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-destructive hover:bg-muted transition-colors"
|
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-destructive hover:bg-muted transition-colors"
|
||||||
title="清除内容"
|
title="清除内容"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
清除
|
清除
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={onCopy}
|
||||||
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-primary hover:bg-muted transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue="editor" className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<div className="flex-1">
|
||||||
<TabsTrigger value="editor">编辑</TabsTrigger>
|
|
||||||
<TabsTrigger value="preview">预览</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="editor" className="flex-1 data-[state=inactive]:hidden">
|
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -587,27 +316,25 @@ export default function WechatEditor() {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleEditorScroll}
|
||||||
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
|
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
|
||||||
placeholder="开始写作..."
|
placeholder="开始写作..."
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onScroll={handleEditorScroll}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="preview" className="flex-1 data-[state=inactive]:hidden">
|
<div className="flex-1">
|
||||||
<div className="h-full overflow-y-auto">
|
<EditorPreview
|
||||||
<EditorPreview
|
previewRef={previewRef}
|
||||||
previewRef={previewRef}
|
selectedTemplate={selectedTemplate}
|
||||||
selectedTemplate={selectedTemplate}
|
previewSize={previewSize}
|
||||||
previewSize={previewSize}
|
isConverting={isConverting}
|
||||||
isConverting={isConverting}
|
previewContent={previewContent}
|
||||||
previewContent={previewContent}
|
codeTheme={codeTheme}
|
||||||
codeTheme={codeTheme}
|
onPreviewSizeChange={setPreviewSize}
|
||||||
onPreviewSizeChange={setPreviewSize}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Split View */}
|
{/* Desktop Split View */}
|
||||||
@ -629,10 +356,10 @@ export default function WechatEditor() {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleEditorScroll}
|
||||||
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
|
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
|
||||||
placeholder="开始写作..."
|
placeholder="开始写作..."
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onScroll={handleEditorScroll}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -651,14 +378,12 @@ export default function WechatEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Type className="h-4 w-4" />
|
|
||||||
<span>{wordCount} 字</span>
|
<span>{wordCount} 字</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mr-4">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mr-4">
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
<span>约 {readingTime}</span>
|
<span>约 {readingTime}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
1
src/components/editor/components/DesktopEditor.tsx
Normal file
1
src/components/editor/components/DesktopEditor.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
@ -1,11 +1,14 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
||||||
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
||||||
import { templates } from '@/config/wechat-templates'
|
import { templates } from '@/config/wechat-templates'
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import { type CodeThemeId } from '@/config/code-themes'
|
import { type CodeThemeId } from '@/config/code-themes'
|
||||||
import { initMermaid } from '@/lib/markdown/mermaid-init'
|
import { useTheme } from 'next-themes'
|
||||||
import '@/styles/code-themes.css'
|
import '@/styles/code-themes.css'
|
||||||
|
import mermaid from 'mermaid'
|
||||||
|
|
||||||
interface EditorPreviewProps {
|
interface EditorPreviewProps {
|
||||||
previewRef: React.RefObject<HTMLDivElement>
|
previewRef: React.RefObject<HTMLDivElement>
|
||||||
@ -29,106 +32,133 @@ export function EditorPreview({
|
|||||||
const [zoom, setZoom] = useState(100)
|
const [zoom, setZoom] = useState(100)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
const isScrolling = useRef<boolean>(false)
|
const isScrolling = useRef<boolean>(false)
|
||||||
const contentRef = useRef<string>('')
|
const { theme } = useTheme()
|
||||||
const renderTimeoutRef = useRef<number>()
|
|
||||||
const stableKeyRef = useRef(`preview-${Date.now()}`)
|
|
||||||
|
|
||||||
// Add useEffect to handle content changes and Mermaid initialization
|
// 初始化 Mermaid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConverting && previewContent) {
|
mermaid.initialize({
|
||||||
// Clear any pending render timeout
|
theme: theme === 'dark' ? 'dark' : 'default',
|
||||||
if (renderTimeoutRef.current) {
|
startOnLoad: false,
|
||||||
window.clearTimeout(renderTimeoutRef.current)
|
securityLevel: 'loose',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 14,
|
||||||
|
flowchart: {
|
||||||
|
htmlLabels: true,
|
||||||
|
curve: 'basis',
|
||||||
|
padding: 15,
|
||||||
|
useMaxWidth: false,
|
||||||
|
defaultRenderer: 'dagre-d3'
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
useMaxWidth: false,
|
||||||
|
boxMargin: 10,
|
||||||
|
mirrorActors: false,
|
||||||
|
bottomMarginAdj: 2,
|
||||||
|
rightAngles: true,
|
||||||
|
showSequenceNumbers: false
|
||||||
|
},
|
||||||
|
pie: {
|
||||||
|
useMaxWidth: true,
|
||||||
|
textPosition: 0.5,
|
||||||
|
useWidth: 800
|
||||||
|
},
|
||||||
|
gantt: {
|
||||||
|
useMaxWidth: false,
|
||||||
|
leftPadding: 75,
|
||||||
|
rightPadding: 20
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
// Set a new timeout to render after content has settled
|
// 使用 memo 包装预览内容
|
||||||
renderTimeoutRef.current = window.setTimeout(() => {
|
const PreviewContent = useMemo(() => {
|
||||||
requestAnimationFrame(() => {
|
return (
|
||||||
initMermaid().catch(error => {
|
<div className={cn(
|
||||||
console.error('Failed to initialize mermaid:', error)
|
"preview-content py-4",
|
||||||
})
|
"prose prose-slate dark:prose-invert max-w-none",
|
||||||
})
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
}, 100) // Wait for 100ms after last content change
|
)}>
|
||||||
}
|
<div
|
||||||
|
className="px-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewContent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [previewContent, selectedTemplate])
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
// 渲染 Mermaid 图表
|
||||||
return () => {
|
|
||||||
if (renderTimeoutRef.current) {
|
|
||||||
window.clearTimeout(renderTimeoutRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isConverting, previewContent])
|
|
||||||
|
|
||||||
// Add useEffect to handle theme changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.querySelector('div.mermaid')) {
|
const renderMermaid = async () => {
|
||||||
if (renderTimeoutRef.current) {
|
try {
|
||||||
window.clearTimeout(renderTimeoutRef.current)
|
const elements = document.querySelectorAll('.mermaid')
|
||||||
}
|
if (!elements.length) return
|
||||||
|
|
||||||
renderTimeoutRef.current = window.setTimeout(() => {
|
// 重新初始化所有图表
|
||||||
requestAnimationFrame(() => {
|
await Promise.all(Array.from(elements).map(async (element) => {
|
||||||
initMermaid().catch(error => {
|
|
||||||
console.error('Failed to initialize mermaid after theme change:', error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}, [codeTheme])
|
|
||||||
|
|
||||||
// Add useEffect to handle copy events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleCopy = async (e: ClipboardEvent) => {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (!selection) return
|
|
||||||
|
|
||||||
const selectedNode = selection.anchorNode?.parentElement
|
|
||||||
if (!selectedNode) return
|
|
||||||
|
|
||||||
// 检查是否在 mermaid 图表内
|
|
||||||
const mermaidElement = selectedNode.closest('.mermaid')
|
|
||||||
if (mermaidElement) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
// 获取渲染后的 SVG 元素
|
|
||||||
const svgElement = mermaidElement.querySelector('svg')
|
|
||||||
if (svgElement) {
|
|
||||||
try {
|
try {
|
||||||
// 创建一个临时的 div 来包含 SVG
|
// 获取内容
|
||||||
const container = document.createElement('div')
|
const content = element.textContent?.trim() || ''
|
||||||
container.appendChild(svgElement.cloneNode(true))
|
if (!content) return
|
||||||
|
|
||||||
// 准备 HTML 和纯文本格式
|
// 清空容器
|
||||||
const htmlContent = container.innerHTML
|
element.innerHTML = ''
|
||||||
const plainText = mermaidElement.querySelector('.mermaid-source')?.textContent || ''
|
|
||||||
|
|
||||||
// 尝试复制为 HTML(保留图表效果)
|
// 重新渲染
|
||||||
await navigator.clipboard.write([
|
const { svg } = await mermaid.render(
|
||||||
new ClipboardItem({
|
`mermaid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
'text/html': new Blob([htmlContent], { type: 'text/html' }),
|
content
|
||||||
'text/plain': new Blob([plainText], { type: 'text/plain' })
|
)
|
||||||
})
|
|
||||||
])
|
// 更新内容
|
||||||
} catch (error) {
|
element.innerHTML = svg
|
||||||
// 如果复制 HTML 失败,退回到复制源代码
|
|
||||||
console.error('Failed to copy as HTML:', error)
|
// 添加暗色模式支持
|
||||||
const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || ''
|
if (theme === 'dark') {
|
||||||
if (e.clipboardData) {
|
const svgElement = element.querySelector('svg')
|
||||||
e.clipboardData.setData('text/plain', sourceText)
|
if (svgElement) {
|
||||||
|
svgElement.style.filter = 'invert(0.85)'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to render mermaid diagram:', {
|
||||||
|
error,
|
||||||
|
element,
|
||||||
|
content: element.textContent
|
||||||
|
})
|
||||||
|
element.innerHTML = `
|
||||||
|
<div class="rounded-lg overflow-hidden border border-red-200">
|
||||||
|
<div class="bg-red-50 p-3 text-red-700 text-sm">
|
||||||
|
Failed to render diagram
|
||||||
|
</div>
|
||||||
|
<pre class="bg-white p-3 m-0 text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
${element.textContent || ''}
|
||||||
|
</pre>
|
||||||
|
<div class="bg-red-50 p-3 text-red-600 text-sm border-t border-red-200">
|
||||||
|
${error instanceof Error ? error.message : 'Unknown error'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
} else {
|
}))
|
||||||
// 如果找不到 SVG,退回到复制源代码
|
} catch (error) {
|
||||||
const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || ''
|
console.error('Failed to initialize mermaid diagrams:', error)
|
||||||
if (e.clipboardData) {
|
|
||||||
e.clipboardData.setData('text/plain', sourceText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('copy', handleCopy)
|
if (!isConverting) {
|
||||||
return () => document.removeEventListener('copy', handleCopy)
|
renderMermaid()
|
||||||
|
}
|
||||||
|
}, [previewContent, theme, isConverting])
|
||||||
|
|
||||||
|
// 监听全屏状态变化
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement)
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
@ -142,24 +172,11 @@ export function EditorPreview({
|
|||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
previewRef.current?.requestFullscreen()
|
previewRef.current?.requestFullscreen()
|
||||||
setIsFullscreen(true)
|
|
||||||
} else {
|
} else {
|
||||||
document.exitFullscreen()
|
document.exitFullscreen()
|
||||||
setIsFullscreen(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 memo 包装预览内容
|
|
||||||
const PreviewContent = useMemo(() => (
|
|
||||||
<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>
|
|
||||||
), [previewContent, selectedTemplate])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
@ -170,7 +187,6 @@ export function EditorPreview({
|
|||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
|
||||||
`code-theme-${codeTheme}`
|
`code-theme-${codeTheme}`
|
||||||
)}
|
)}
|
||||||
key={stableKeyRef.current}
|
|
||||||
>
|
>
|
||||||
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
|
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
|
||||||
<div className="flex items-center gap-0.5 px-2">
|
<div className="flex items-center gap-0.5 px-2">
|
||||||
@ -198,7 +214,7 @@ export function EditorPreview({
|
|||||||
<select
|
<select
|
||||||
value={previewSize}
|
value={previewSize}
|
||||||
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)}
|
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)}
|
||||||
className="text-sm border rounded px-2 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
|
className="text-sm border rounded px-2 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
|
||||||
>
|
>
|
||||||
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
|
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
|
||||||
<option key={key} value={key}>{label}</option>
|
<option key={key} value={key}>{label}</option>
|
||||||
@ -218,22 +234,25 @@ export function EditorPreview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto" onScroll={(e) => {
|
<div
|
||||||
const container = e.currentTarget
|
className="flex-1 overflow-y-auto"
|
||||||
const textarea = document.querySelector('.editor-container textarea')
|
onScroll={(e) => {
|
||||||
if (!textarea || isScrolling.current) return
|
const container = e.currentTarget
|
||||||
isScrolling.current = true
|
const textarea = document.querySelector('.editor-container textarea')
|
||||||
|
if (!textarea || isScrolling.current) return
|
||||||
|
isScrolling.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scrollPercentage = container.scrollTop / (container.scrollHeight - container.clientHeight)
|
const scrollPercentage = container.scrollTop / (container.scrollHeight - container.clientHeight)
|
||||||
const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight)
|
const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight)
|
||||||
textarea.scrollTop = textareaScrollTop
|
textarea.scrollTop = textareaScrollTop
|
||||||
} finally {
|
} finally {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
isScrolling.current = false
|
isScrolling.current = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<div className="h-full py-8 px-4">
|
<div className="h-full py-8 px-4">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
1
src/components/editor/components/EditorStatusBar.tsx
Normal file
1
src/components/editor/components/EditorStatusBar.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
1
src/components/editor/components/MobileEditor.tsx
Normal file
1
src/components/editor/components/MobileEditor.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
90
src/components/editor/hooks/useCopy.ts
Normal file
90
src/components/editor/hooks/useCopy.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { initializeMermaid } from '@/lib/markdown/mermaid-utils'
|
||||||
|
|
||||||
|
export const useCopy = () => {
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(async (content: string) => {
|
||||||
|
try {
|
||||||
|
// 创建临时容器并渲染内容
|
||||||
|
const tempDiv = document.createElement('div')
|
||||||
|
tempDiv.innerHTML = content
|
||||||
|
document.body.appendChild(tempDiv)
|
||||||
|
|
||||||
|
// 处理 Mermaid 图表
|
||||||
|
const mermaidElements = tempDiv.querySelectorAll('.mermaid')
|
||||||
|
if (mermaidElements.length > 0) {
|
||||||
|
// 重新初始化 Mermaid
|
||||||
|
await initializeMermaid()
|
||||||
|
// 等待渲染完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取处理后的内容
|
||||||
|
const processedContent = tempDiv.innerHTML
|
||||||
|
|
||||||
|
// 清理临时 div
|
||||||
|
document.body.removeChild(tempDiv)
|
||||||
|
|
||||||
|
// 使用 Clipboard API 写入富文本
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'text/html': new Blob([processedContent], { type: 'text/html' }),
|
||||||
|
'text/plain': new Blob([content.replace(/<[^>]+>/g, '')], { type: 'text/plain' })
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "复制成功",
|
||||||
|
description: "内容已复制,可直接粘贴到公众号编辑器",
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy error:', error)
|
||||||
|
try {
|
||||||
|
// 降级处理:尝试以纯文本方式复制
|
||||||
|
await navigator.clipboard.writeText(content.replace(/<[^>]+>/g, ''))
|
||||||
|
toast({
|
||||||
|
title: "复制成功",
|
||||||
|
description: "已复制为纯文本内容",
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (fallbackError) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "复制失败",
|
||||||
|
description: "无法访问剪贴板,请检查浏览器权限",
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async (selection: Selection | null, content: string) => {
|
||||||
|
try {
|
||||||
|
// 如果有选中的文本,使用选中的内容
|
||||||
|
if (selection && !selection.isCollapsed) {
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const container = range.cloneContents()
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.appendChild(container)
|
||||||
|
return await copyToClipboard(div.innerHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则复制整个内容
|
||||||
|
return await copyToClipboard(content)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Handle copy error:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [copyToClipboard])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleCopy,
|
||||||
|
copyToClipboard
|
||||||
|
}
|
||||||
|
}
|
47
src/components/editor/hooks/useEditorKeyboard.ts
Normal file
47
src/components/editor/hooks/useEditorKeyboard.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface UseEditorKeyboardProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorKeyboard = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSave
|
||||||
|
}: UseEditorKeyboardProps) => {
|
||||||
|
// 处理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)
|
||||||
|
onChange(newValue)
|
||||||
|
|
||||||
|
// 恢复光标位置
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [value, onChange])
|
||||||
|
|
||||||
|
// 监听快捷键保存事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
|
||||||
|
}, [onSave])
|
||||||
|
|
||||||
|
return { handleKeyDown }
|
||||||
|
}
|
158
src/components/editor/hooks/usePreviewContent.ts
Normal file
158
src/components/editor/hooks/usePreviewContent.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { templates } from '@/config/wechat-templates'
|
||||||
|
import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown'
|
||||||
|
import { type CodeThemeId } from '@/config/code-themes'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { initializeMermaid } from '@/lib/markdown/mermaid-utils'
|
||||||
|
|
||||||
|
interface UsePreviewContentProps {
|
||||||
|
value: string
|
||||||
|
selectedTemplate: string
|
||||||
|
styleOptions: RendererOptions
|
||||||
|
codeTheme: CodeThemeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreviewContent = ({
|
||||||
|
value,
|
||||||
|
selectedTemplate,
|
||||||
|
styleOptions,
|
||||||
|
codeTheme
|
||||||
|
}: UsePreviewContentProps) => {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [isConverting, setIsConverting] = useState(false)
|
||||||
|
const [previewContent, setPreviewContent] = useState('')
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePreview = async () => {
|
||||||
|
if (!value) {
|
||||||
|
setPreviewContent('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConverting(true)
|
||||||
|
try {
|
||||||
|
const content = getPreviewContent()
|
||||||
|
setPreviewContent(content)
|
||||||
|
|
||||||
|
// 等待 DOM 更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
// 渲染 Mermaid 图表
|
||||||
|
try {
|
||||||
|
await 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])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConverting,
|
||||||
|
previewContent,
|
||||||
|
getPreviewContent
|
||||||
|
}
|
||||||
|
}
|
49
src/components/editor/hooks/useScrollSync.ts
Normal file
49
src/components/editor/hooks/useScrollSync.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export const useScrollSync = () => {
|
||||||
|
const isScrolling = useRef<boolean>(false)
|
||||||
|
const scrollTimeout = useRef<NodeJS.Timeout>()
|
||||||
|
const lastScrollTop = useRef<number>(0)
|
||||||
|
|
||||||
|
const handleEditorScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
// 如果是由于输入导致的滚动,不进行同步
|
||||||
|
if (e.currentTarget.selectionStart !== e.currentTarget.selectionEnd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
previewContainer.scrollTo({
|
||||||
|
top: targetScrollTop,
|
||||||
|
behavior: 'instant'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (scrollTimeout.current) {
|
||||||
|
clearTimeout(scrollTimeout.current)
|
||||||
|
}
|
||||||
|
scrollTimeout.current = setTimeout(() => {
|
||||||
|
isScrolling.current = false
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { handleEditorScroll }
|
||||||
|
}
|
27
src/components/editor/hooks/useWordStats.ts
Normal file
27
src/components/editor/hooks/useWordStats.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
// 计算阅读时间(假设每分钟阅读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 const useWordStats = (content: string) => {
|
||||||
|
const [wordCount, setWordCount] = useState('0')
|
||||||
|
const [readingTime, setReadingTime] = useState('1 分钟')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const plainText = content.replace(/<[^>]+>/g, '')
|
||||||
|
setWordCount(calculateWordCount(plainText))
|
||||||
|
setReadingTime(calculateReadingTime(plainText))
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
return { wordCount, readingTime }
|
||||||
|
}
|
@ -1,266 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { initMermaid } from '@/lib/markdown/mermaid-init'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
mermaid: {
|
|
||||||
init: (config: any, nodes: NodeListOf<Element>) => Promise<void>
|
|
||||||
initialize: (config: any) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mermaid 配置
|
|
||||||
export const MERMAID_CONFIG = {
|
|
||||||
theme: 'neutral',
|
|
||||||
themeVariables: {
|
|
||||||
primaryColor: '#f5f8fe',
|
|
||||||
primaryBorderColor: '#c9e0ff',
|
|
||||||
primaryTextColor: '#000000',
|
|
||||||
lineColor: '#000000',
|
|
||||||
textColor: '#000000',
|
|
||||||
fontSize: '14px'
|
|
||||||
},
|
|
||||||
flowchart: {
|
|
||||||
htmlLabels: true,
|
|
||||||
curve: 'basis',
|
|
||||||
padding: 15,
|
|
||||||
nodeSpacing: 50,
|
|
||||||
rankSpacing: 50,
|
|
||||||
useMaxWidth: false
|
|
||||||
},
|
|
||||||
sequence: {
|
|
||||||
useMaxWidth: false,
|
|
||||||
boxMargin: 10,
|
|
||||||
mirrorActors: false,
|
|
||||||
bottomMarginAdj: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 Mermaid
|
|
||||||
*/
|
|
||||||
export async function initializeMermaid() {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
try {
|
|
||||||
await initMermaid()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Mermaid initialization error:', error)
|
|
||||||
// 即使初始化失败也继续执行,不阻塞整个应用
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToastFunction = {
|
|
||||||
(props: {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
action?: React.ReactElement
|
|
||||||
duration?: number
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CopyHandlerOptions {
|
|
||||||
toast: ToastFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理 Mermaid 图表的复制
|
|
||||||
*/
|
|
||||||
async function handleMermaidCopy(mermaidElement: Element): Promise<boolean> {
|
|
||||||
const svgElement = mermaidElement.querySelector('svg')
|
|
||||||
if (!svgElement) return false
|
|
||||||
|
|
||||||
// 获取原始的 Mermaid 代码
|
|
||||||
const originalCode = mermaidElement.getAttribute('data-source') || ''
|
|
||||||
|
|
||||||
// 创建一个临时的 canvas 来转换 SVG 为图片
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (!ctx) throw new Error('Failed to get canvas context')
|
|
||||||
|
|
||||||
// 设置 canvas 尺寸为 SVG 的实际尺寸
|
|
||||||
const svgRect = svgElement.getBoundingClientRect()
|
|
||||||
canvas.width = svgRect.width
|
|
||||||
canvas.height = svgRect.height
|
|
||||||
|
|
||||||
// 创建图片对象
|
|
||||||
const img = new Image()
|
|
||||||
const svgData = new XMLSerializer().serializeToString(svgElement)
|
|
||||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(svgBlob)
|
|
||||||
|
|
||||||
// 等待图片加载
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve
|
|
||||||
img.onerror = reject
|
|
||||||
img.src = url
|
|
||||||
})
|
|
||||||
|
|
||||||
// 绘制图片到 canvas
|
|
||||||
ctx.fillStyle = '#FFFFFF'
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
ctx.drawImage(img, 0, 0)
|
|
||||||
|
|
||||||
// 转换为 PNG
|
|
||||||
const pngBlob = await new Promise<Blob>((resolve) => {
|
|
||||||
canvas.toBlob((blob) => resolve(blob!), 'image/png', 1.0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 清理
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
// 复制为图片、HTML 和原始文本
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'image/png': pngBlob,
|
|
||||||
'text/html': new Blob([svgElement.outerHTML], { type: 'text/html' }),
|
|
||||||
'text/plain': new Blob([originalCode], { type: 'text/plain' })
|
|
||||||
})
|
|
||||||
])
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理预览内容的复制
|
|
||||||
*/
|
|
||||||
async function handlePreviewCopy(previewContent: string): Promise<boolean> {
|
|
||||||
// 创建临时容器来处理内容
|
|
||||||
const tempDiv = document.createElement('div')
|
|
||||||
tempDiv.innerHTML = previewContent
|
|
||||||
|
|
||||||
// 首先找到所有 Mermaid 图表的源代码
|
|
||||||
const mermaidElements = tempDiv.querySelectorAll('.mermaid')
|
|
||||||
if (mermaidElements.length > 0) {
|
|
||||||
try {
|
|
||||||
await initMermaid()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Mermaid rendering error:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理渲染后的 SVG
|
|
||||||
const mermaidDiagrams = tempDiv.querySelectorAll('.mermaid svg')
|
|
||||||
mermaidDiagrams.forEach(svg => {
|
|
||||||
const container = svg.closest('.mermaid')
|
|
||||||
if (container) {
|
|
||||||
// 设置 SVG 的样式
|
|
||||||
const svgElement = svg as SVGElement
|
|
||||||
Object.assign(svgElement.style, {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
maxWidth: '100%',
|
|
||||||
width: '100%',
|
|
||||||
height: 'auto'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 更新图表容器的样式
|
|
||||||
container.setAttribute('style', `
|
|
||||||
background-color: transparent;
|
|
||||||
padding: 0;
|
|
||||||
margin: 16px 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取处理后的 HTML 内容
|
|
||||||
const htmlContent = tempDiv.innerHTML
|
|
||||||
const plainText = tempDiv.textContent || tempDiv.innerText
|
|
||||||
|
|
||||||
// 复制到剪贴板,添加必要的样式
|
|
||||||
const styledHtml = `
|
|
||||||
<div style="
|
|
||||||
background-color: transparent;
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
color: #000000;
|
|
||||||
line-height: 1.5;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
">
|
|
||||||
${htmlContent}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'text/html': new Blob([styledHtml], { type: 'text/html' }),
|
|
||||||
'text/plain': new Blob([plainText], { type: 'text/plain' })
|
|
||||||
})
|
|
||||||
])
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制处理器
|
|
||||||
*/
|
|
||||||
export async function copyHandler(
|
|
||||||
selection: Selection | null,
|
|
||||||
previewContent: string,
|
|
||||||
options: CopyHandlerOptions
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { toast } = options
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 检查是否有选中的 Mermaid 图表
|
|
||||||
if (selection && !selection.isCollapsed) {
|
|
||||||
const selectedNode = selection.anchorNode?.parentElement
|
|
||||||
if (selectedNode) {
|
|
||||||
const mermaidElement = selectedNode.closest('.mermaid')
|
|
||||||
if (mermaidElement) {
|
|
||||||
const success = await handleMermaidCopy(mermaidElement)
|
|
||||||
if (success) {
|
|
||||||
toast({
|
|
||||||
title: "复制成功",
|
|
||||||
description: "已复制图表(支持粘贴为图片或源代码)",
|
|
||||||
duration: 2000
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制整个预览内容
|
|
||||||
const success = await handlePreviewCopy(previewContent)
|
|
||||||
if (success) {
|
|
||||||
toast({
|
|
||||||
title: "复制成功",
|
|
||||||
description: "已复制预览内容",
|
|
||||||
duration: 2000
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy error:', err)
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(previewContent)
|
|
||||||
toast({
|
|
||||||
title: "复制成功",
|
|
||||||
description: "已复制预览内容(仅文本)",
|
|
||||||
duration: 2000
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "复制失败",
|
|
||||||
description: "无法访问剪贴板,请检查浏览器权限",
|
|
||||||
action: (
|
|
||||||
<button onClick={() => window.location.reload()} className="hover:bg-secondary">
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
92
src/lib/markdown/components/MermaidRenderer.tsx
Normal file
92
src/lib/markdown/components/MermaidRenderer.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { MermaidRendererProps } from '../types/mermaid'
|
||||||
|
import { getCurrentTheme, initializeMermaid, renderMermaidDiagram } from '../utils/mermaid'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
export function MermaidRenderer({
|
||||||
|
content,
|
||||||
|
className = '',
|
||||||
|
config,
|
||||||
|
onRender,
|
||||||
|
onError
|
||||||
|
}: MermaidRendererProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const renderDiagram = async () => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 初始化 mermaid
|
||||||
|
const currentTheme = getCurrentTheme()
|
||||||
|
initializeMermaid(currentTheme, config)
|
||||||
|
|
||||||
|
// 清空容器内容
|
||||||
|
containerRef.current.innerHTML = ''
|
||||||
|
|
||||||
|
// 创建新的渲染容器
|
||||||
|
const renderContainer = document.createElement('div')
|
||||||
|
renderContainer.className = 'mermaid-render'
|
||||||
|
containerRef.current.appendChild(renderContainer)
|
||||||
|
|
||||||
|
// 渲染图表
|
||||||
|
const { svg } = await renderMermaidDiagram(content, config)
|
||||||
|
|
||||||
|
// 更新内容
|
||||||
|
if (containerRef.current) {
|
||||||
|
renderContainer.innerHTML = svg
|
||||||
|
// 添加原始内容的隐藏元素,用于复制等功能
|
||||||
|
const sourceElement = document.createElement('div')
|
||||||
|
sourceElement.className = 'mermaid-source'
|
||||||
|
sourceElement.style.display = 'none'
|
||||||
|
sourceElement.textContent = content
|
||||||
|
containerRef.current.appendChild(sourceElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用渲染完成回调
|
||||||
|
onRender?.(svg)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to render mermaid diagram:', error)
|
||||||
|
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
// 显示错误信息
|
||||||
|
containerRef.current.innerHTML = `
|
||||||
|
<div class="mermaid-error rounded-lg overflow-hidden border border-red-200">
|
||||||
|
<div class="bg-red-50 p-3 text-red-700 text-sm">
|
||||||
|
Mermaid 语法错误
|
||||||
|
</div>
|
||||||
|
<pre class="mermaid-source bg-white p-3 m-0 text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
${content}
|
||||||
|
</pre>
|
||||||
|
<div class="bg-red-50 p-3 text-red-600 text-sm border-t border-red-200 whitespace-pre-wrap break-all">
|
||||||
|
${error instanceof Error ? error.message : '未知错误'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// 调用错误回调
|
||||||
|
if (error instanceof Error) {
|
||||||
|
onError?.({
|
||||||
|
content,
|
||||||
|
message: error.message,
|
||||||
|
error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDiagram()
|
||||||
|
}, [content, config, theme, onRender, onError])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`mermaid ${className}`}
|
||||||
|
data-theme={theme}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
62
src/lib/markdown/config/mermaid.ts
Normal file
62
src/lib/markdown/config/mermaid.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { MermaidConfig } from 'mermaid'
|
||||||
|
import type { MermaidTheme, MermaidThemeVariables } from '../types/mermaid'
|
||||||
|
|
||||||
|
const defaultThemeVariables: MermaidThemeVariables = {
|
||||||
|
primaryColor: '#4f46e5',
|
||||||
|
primaryBorderColor: '#4f46e5',
|
||||||
|
primaryTextColor: '#000000',
|
||||||
|
lineColor: '#666666',
|
||||||
|
textColor: '#333333',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif'
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkThemeVariables: MermaidThemeVariables = {
|
||||||
|
primaryColor: '#6366f1',
|
||||||
|
primaryBorderColor: '#6366f1',
|
||||||
|
primaryTextColor: '#ffffff',
|
||||||
|
lineColor: '#999999',
|
||||||
|
textColor: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: defaultThemeVariables.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeVariables: Record<MermaidTheme, MermaidThemeVariables> = {
|
||||||
|
default: defaultThemeVariables,
|
||||||
|
dark: darkThemeVariables,
|
||||||
|
neutral: {
|
||||||
|
...defaultThemeVariables,
|
||||||
|
primaryColor: '#6b7280',
|
||||||
|
primaryBorderColor: '#6b7280'
|
||||||
|
},
|
||||||
|
forest: {
|
||||||
|
...defaultThemeVariables,
|
||||||
|
primaryColor: '#059669',
|
||||||
|
primaryBorderColor: '#059669'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMermaidConfig = (theme: MermaidTheme = 'default'): MermaidConfig => ({
|
||||||
|
theme: 'base',
|
||||||
|
themeVariables: themeVariables[theme],
|
||||||
|
flowchart: {
|
||||||
|
htmlLabels: true,
|
||||||
|
curve: 'basis',
|
||||||
|
padding: 15,
|
||||||
|
nodeSpacing: 50,
|
||||||
|
rankSpacing: 50,
|
||||||
|
useMaxWidth: false
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
useMaxWidth: false,
|
||||||
|
boxMargin: 10,
|
||||||
|
mirrorActors: false,
|
||||||
|
bottomMarginAdj: 2
|
||||||
|
},
|
||||||
|
pie: {
|
||||||
|
textPosition: 0.75,
|
||||||
|
useMaxWidth: true
|
||||||
|
},
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: 'loose'
|
||||||
|
})
|
84
src/lib/markdown/mermaid-utils.ts
Normal file
84
src/lib/markdown/mermaid-utils.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import mermaid from 'mermaid'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
mermaid: {
|
||||||
|
init: (config: any, nodes: NodeListOf<Element>) => Promise<void>
|
||||||
|
initialize: (config: any) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mermaid 配置
|
||||||
|
export const MERMAID_CONFIG = {
|
||||||
|
theme: 'default' as const,
|
||||||
|
themeVariables: {
|
||||||
|
primaryColor: '#4f46e5',
|
||||||
|
primaryBorderColor: '#4f46e5',
|
||||||
|
primaryTextColor: '#000000',
|
||||||
|
lineColor: '#666666',
|
||||||
|
textColor: '#333333',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif'
|
||||||
|
},
|
||||||
|
flowchart: {
|
||||||
|
htmlLabels: true,
|
||||||
|
curve: 'basis' as const,
|
||||||
|
padding: 15,
|
||||||
|
nodeSpacing: 50,
|
||||||
|
rankSpacing: 50,
|
||||||
|
useMaxWidth: false
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
useMaxWidth: false,
|
||||||
|
boxMargin: 10,
|
||||||
|
mirrorActors: false,
|
||||||
|
bottomMarginAdj: 2
|
||||||
|
},
|
||||||
|
pie: {
|
||||||
|
textPosition: 0.75,
|
||||||
|
useMaxWidth: true
|
||||||
|
},
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: 'loose' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 Mermaid
|
||||||
|
*/
|
||||||
|
export const initializeMermaid = async () => {
|
||||||
|
try {
|
||||||
|
// 初始化配置
|
||||||
|
mermaid.initialize(MERMAID_CONFIG)
|
||||||
|
|
||||||
|
// 获取所有 mermaid 图表
|
||||||
|
const elements = document.querySelectorAll('.mermaid')
|
||||||
|
if (!elements.length) return
|
||||||
|
|
||||||
|
// 遍历并渲染每个图表
|
||||||
|
for (const element of Array.from(elements)) {
|
||||||
|
try {
|
||||||
|
// 如果已经渲染过,跳过
|
||||||
|
if (element.querySelector('svg')) continue
|
||||||
|
|
||||||
|
// 获取图表源码
|
||||||
|
const source = element.textContent || ''
|
||||||
|
if (!source.trim()) continue
|
||||||
|
|
||||||
|
// 渲染图表
|
||||||
|
const { svg } = await mermaid.render(
|
||||||
|
'mermaid-' + Math.random().toString(36).substring(2),
|
||||||
|
source
|
||||||
|
)
|
||||||
|
|
||||||
|
// 更新内容
|
||||||
|
element.innerHTML = svg
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to render mermaid diagram:', err)
|
||||||
|
element.innerHTML = `<div class="mermaid-error">图表渲染失败</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to initialize mermaid:', err)
|
||||||
|
}
|
||||||
|
}
|
@ -68,6 +68,82 @@ export class MarkdownParser {
|
|||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 添加 Mermaid 支持
|
||||||
|
marked.use({
|
||||||
|
extensions: [{
|
||||||
|
name: 'mermaid',
|
||||||
|
level: 'block',
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/^```mermaid\s*\n/)?.index
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = /^```mermaid\s*\n([\s\S]*?)\n\s*```/.exec(src)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: 'mermaid',
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
tokens: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
try {
|
||||||
|
const lines = token.text.split('\n')
|
||||||
|
.map((line: string) => line.trim())
|
||||||
|
.filter(Boolean) // 移除空行
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return '<pre class="mermaid-error">Empty diagram content</pre>'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取第一行作为图表类型
|
||||||
|
const firstLine = lines[0].toLowerCase()
|
||||||
|
let formattedContent: string
|
||||||
|
|
||||||
|
if (firstLine.startsWith('pie')) {
|
||||||
|
// 处理饼图
|
||||||
|
formattedContent = 'pie\n' + lines.slice(1).map((line: string) => {
|
||||||
|
if (line.toLowerCase().startsWith('title')) {
|
||||||
|
return ` title ${line.substring(5).trim()}`
|
||||||
|
}
|
||||||
|
if (line.includes(':')) {
|
||||||
|
const [key, value] = line.split(':').map((part: string) => part.trim())
|
||||||
|
const formattedKey = key.startsWith('"') && key.endsWith('"') ? key : `"${key}"`
|
||||||
|
return ` ${formattedKey}:${value}`
|
||||||
|
}
|
||||||
|
return ` ${line}`
|
||||||
|
}).join('\n')
|
||||||
|
} else {
|
||||||
|
// 其他类型的图表
|
||||||
|
formattedContent = lines.map((line: string, index: number) => {
|
||||||
|
if (index === 0) {
|
||||||
|
// 处理第一行(图表类型)
|
||||||
|
if (line.toLowerCase().includes('sequence')) {
|
||||||
|
return 'sequenceDiagram'
|
||||||
|
}
|
||||||
|
if (line.toLowerCase().includes('flow') || line.toLowerCase().includes('graph')) {
|
||||||
|
return line.toLowerCase().startsWith('graph') ? line : `graph ${line.split(' ')[1] || 'TD'}`
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
// 其他行添加缩进
|
||||||
|
return ` ${line}`
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Formatted Mermaid content:', formattedContent)
|
||||||
|
return `<div class="mermaid">\n${formattedContent}\n</div>`
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error processing Mermaid content:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return `<pre class="mermaid-error">Error rendering diagram: ${errorMessage}</pre>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public parse(markdown: string): string {
|
public parse(markdown: string): string {
|
||||||
|
35
src/lib/markdown/types/mermaid.ts
Normal file
35
src/lib/markdown/types/mermaid.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { MermaidConfig } from 'mermaid'
|
||||||
|
|
||||||
|
export interface MermaidRendererProps {
|
||||||
|
/** Mermaid 图表的源代码 */
|
||||||
|
content: string
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string
|
||||||
|
/** 自定义配置,会与默认配置合并 */
|
||||||
|
config?: Partial<MermaidConfig>
|
||||||
|
/** 渲染完成的回调 */
|
||||||
|
onRender?: (svg: string) => void
|
||||||
|
/** 渲染失败的回调 */
|
||||||
|
onError?: (error: MermaidError) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MermaidError {
|
||||||
|
/** 原始内容 */
|
||||||
|
content: string
|
||||||
|
/** 错误信息 */
|
||||||
|
message: string
|
||||||
|
/** 错误对象 */
|
||||||
|
error: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MermaidThemeVariables {
|
||||||
|
primaryColor: string
|
||||||
|
primaryBorderColor: string
|
||||||
|
primaryTextColor: string
|
||||||
|
lineColor: string
|
||||||
|
textColor: string
|
||||||
|
fontSize: string
|
||||||
|
fontFamily: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MermaidTheme = 'default' | 'dark' | 'neutral' | 'forest'
|
86
src/lib/markdown/utils/mermaid.ts
Normal file
86
src/lib/markdown/utils/mermaid.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import mermaid from 'mermaid'
|
||||||
|
import type { MermaidConfig } from 'mermaid'
|
||||||
|
import type { MermaidError, MermaidTheme } from '../types/mermaid'
|
||||||
|
import { createMermaidConfig } from '../config/mermaid'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 Mermaid
|
||||||
|
*/
|
||||||
|
export const initializeMermaid = (theme: MermaidTheme = 'default', config?: Partial<MermaidConfig>) => {
|
||||||
|
const defaultConfig = createMermaidConfig(theme)
|
||||||
|
mermaid.initialize({
|
||||||
|
...defaultConfig,
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染单个 Mermaid 图表
|
||||||
|
*/
|
||||||
|
export const renderMermaidDiagram = async (
|
||||||
|
content: string,
|
||||||
|
config?: Partial<MermaidConfig>
|
||||||
|
): Promise<{ svg: string }> => {
|
||||||
|
try {
|
||||||
|
// 先尝试解析,检查语法错误
|
||||||
|
await mermaid.parse(content)
|
||||||
|
|
||||||
|
// 生成唯一 ID
|
||||||
|
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substring(2)}`
|
||||||
|
|
||||||
|
// 渲染图表
|
||||||
|
return await mermaid.render(id, content)
|
||||||
|
} catch (error) {
|
||||||
|
const mermaidError: MermaidError = {
|
||||||
|
content,
|
||||||
|
message: error instanceof Error ? error.message : '未知错误',
|
||||||
|
error: error instanceof Error ? error : new Error('未知错误')
|
||||||
|
}
|
||||||
|
throw mermaidError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量渲染页面中的 Mermaid 图表
|
||||||
|
*/
|
||||||
|
export const renderMermaidDiagrams = async (selector = '.mermaid') => {
|
||||||
|
try {
|
||||||
|
const elements = document.querySelectorAll<HTMLElement>(selector)
|
||||||
|
if (!elements.length) return
|
||||||
|
|
||||||
|
for (const element of Array.from(elements)) {
|
||||||
|
try {
|
||||||
|
// 如果已经渲染过,跳过
|
||||||
|
if (element.querySelector('svg')) continue
|
||||||
|
|
||||||
|
// 获取图表源码
|
||||||
|
const source = element.textContent?.trim() || ''
|
||||||
|
if (!source) continue
|
||||||
|
|
||||||
|
// 渲染图表
|
||||||
|
const { svg } = await renderMermaidDiagram(source)
|
||||||
|
element.innerHTML = svg
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to render mermaid diagram:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '图表渲染失败'
|
||||||
|
const errorContent = element.textContent?.trim() || ''
|
||||||
|
element.innerHTML = `
|
||||||
|
<div class="mermaid-error rounded-lg overflow-hidden border border-red-200">
|
||||||
|
<div class="bg-red-50 p-3 text-red-700 text-sm">${errorMessage}</div>
|
||||||
|
<pre class="bg-white p-3 m-0 text-sm overflow-x-auto">${errorContent}</pre>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize mermaid:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前主题
|
||||||
|
*/
|
||||||
|
export const getCurrentTheme = (): MermaidTheme => {
|
||||||
|
if (typeof window === 'undefined') return 'default'
|
||||||
|
return document.documentElement.classList.contains('dark') ? 'dark' : 'default'
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user