重构代码
This commit is contained in:
		
							parent
							
								
									764add8de7
								
							
						
					
					
						commit
						af36766729
					
				| @ -30,6 +30,7 @@ | ||||
|     "@types/marked": "^6.0.0", | ||||
|     "@types/prismjs": "^1.26.5", | ||||
|     "class-variance-authority": "^0.7.1", | ||||
|     "clipboard-polyfill": "^4.1.1", | ||||
|     "clsx": "^2.1.1", | ||||
|     "katex": "^0.16.21", | ||||
|     "lucide-react": "^0.474.0", | ||||
|  | ||||
| @ -71,6 +71,9 @@ importers: | ||||
|       class-variance-authority: | ||||
|         specifier: ^0.7.1 | ||||
|         version: 0.7.1 | ||||
|       clipboard-polyfill: | ||||
|         specifier: ^4.1.1 | ||||
|         version: 4.1.1 | ||||
|       clsx: | ||||
|         specifier: ^2.1.1 | ||||
|         version: 2.1.1 | ||||
| @ -1110,6 +1113,9 @@ packages: | ||||
|   client-only@0.0.1: | ||||
|     resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | ||||
| 
 | ||||
|   clipboard-polyfill@4.1.1: | ||||
|     resolution: {integrity: sha512-nbvNLrcX0zviek5QHLFRAaLrx8y/s8+RF2stH43tuS+kP5XlHMrcD0UGBWq43Hwp6WuuK7KefRMP56S45ibZkA==} | ||||
| 
 | ||||
|   clone@1.0.4: | ||||
|     resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} | ||||
|     engines: {node: '>=0.8'} | ||||
| @ -3229,6 +3235,8 @@ snapshots: | ||||
| 
 | ||||
|   client-only@0.0.1: {} | ||||
| 
 | ||||
|   clipboard-polyfill@4.1.1: {} | ||||
| 
 | ||||
|   clone@1.0.4: {} | ||||
| 
 | ||||
|   clsx@2.1.1: {} | ||||
|  | ||||
| @ -1,217 +1,45 @@ | ||||
| 'use client' | ||||
| 
 | ||||
| import { useState, useCallback, useEffect, useRef } from 'react' | ||||
| import { templates } from '@/config/wechat-templates' | ||||
| import { cn } from '@/lib/utils' | ||||
| import { useState, useCallback, useRef, useEffect } from 'react' | ||||
| import { useToast } from '@/components/ui/use-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 { useAutoSave } from './hooks/useAutoSave' | ||||
| import { EditorToolbar } from './components/EditorToolbar' | ||||
| import { EditorPreview } from './components/EditorPreview' | ||||
| import { MarkdownToolbar } from './components/MarkdownToolbar' | ||||
| import { type PreviewSize } from './constants' | ||||
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' | ||||
| import { WechatStylePicker } from '@/components/template/WechatStylePicker' | ||||
| import { Copy, Clock, Type, Trash2 } from 'lucide-react' | ||||
| import { useLocalStorage } from '@/hooks/use-local-storage' | ||||
| import { codeThemes, type CodeThemeId } from '@/config/code-themes' | ||||
| import '@/styles/code-themes.css' | ||||
| import { copyHandler, initializeMermaid, MERMAID_CONFIG } from './utils/copy-handler' | ||||
| 
 | ||||
| // 计算阅读时间(假设每分钟阅读300字)
 | ||||
| const calculateReadingTime = (text: string): string => { | ||||
|   const words = text.trim().length | ||||
|   const minutes = Math.ceil(words / 300) | ||||
|   return `${minutes} 分钟` | ||||
| } | ||||
| 
 | ||||
| // 计算字数
 | ||||
| const calculateWordCount = (text: string): string => { | ||||
|   const count = text.trim().length | ||||
|   return count.toLocaleString() | ||||
| } | ||||
| import { templates } from '@/config/wechat-templates' | ||||
| import { cn } from '@/lib/utils' | ||||
| import { usePreviewContent } from './hooks/usePreviewContent' | ||||
| import { useEditorKeyboard } from './hooks/useEditorKeyboard' | ||||
| import { useScrollSync } from './hooks/useScrollSync' | ||||
| import { useWordStats } from './hooks/useWordStats' | ||||
| import { useCopy } from './hooks/useCopy' | ||||
| 
 | ||||
| export default function WechatEditor() { | ||||
|   const { toast } = useToast() | ||||
|   const editorRef = useRef<HTMLDivElement>(null) | ||||
|   const textareaRef = useRef<HTMLTextAreaElement>(null) | ||||
|   const previewRef = useRef<HTMLDivElement>(null) | ||||
|    | ||||
|   // 状态管理
 | ||||
|   const [value, setValue] = useState('') | ||||
|   const [selectedTemplate, setSelectedTemplate] = useState<string>('default') | ||||
|   const [showPreview, setShowPreview] = useState(true) | ||||
|   const [styleOptions, setStyleOptions] = useState<RendererOptions>({}) | ||||
|   const [previewSize, setPreviewSize] = useState<PreviewSize>('medium') | ||||
|   const [isConverting, setIsConverting] = useState(false) | ||||
|   const [isDraft, setIsDraft] = useState(false) | ||||
|   const [previewContent, setPreviewContent] = useState('') | ||||
|   const [cursorPosition, setCursorPosition] = useState<{ start: number; end: number }>({ start: 0, end: 0 }) | ||||
| 
 | ||||
|   // 添加字数和阅读时间状态
 | ||||
|   const [wordCount, setWordCount] = useState('0') | ||||
|   const [readingTime, setReadingTime] = useState('1 分钟') | ||||
| 
 | ||||
|   // 添加 codeTheme 状态
 | ||||
|   const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id) | ||||
| 
 | ||||
|   // 使用自定义 hooks
 | ||||
|   const { handleScroll } = useEditorSync(editorRef) | ||||
|   const { handleEditorChange } = useAutoSave(value, setIsDraft) | ||||
| 
 | ||||
|   // 初始化 Mermaid
 | ||||
|   useEffect(() => { | ||||
|     const init = async () => { | ||||
|       await initializeMermaid() | ||||
|     } | ||||
|     init() | ||||
|   }, []) | ||||
| 
 | ||||
|   // 处理编辑器输入
 | ||||
|   const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { | ||||
|     const newValue = e.target.value | ||||
|     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 { handleEditorScroll } = useScrollSync() | ||||
| 
 | ||||
|   // 手动保存
 | ||||
|   const handleSave = useCallback(() => { | ||||
| @ -233,78 +61,53 @@ export default function WechatEditor() { | ||||
|     } | ||||
|   }, [value, toast]) | ||||
| 
 | ||||
|   // 监听快捷键保存事件
 | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => { | ||||
|       if ((e.metaKey || e.ctrlKey) && e.key === 's') { | ||||
|         e.preventDefault() | ||||
|         handleSave() | ||||
|       } | ||||
|   const { isConverting, previewContent } = usePreviewContent({ | ||||
|     value, | ||||
|     selectedTemplate, | ||||
|     styleOptions, | ||||
|     codeTheme | ||||
|   }) | ||||
| 
 | ||||
|   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) | ||||
|     return () => window.removeEventListener('keydown', handleKeyDown) | ||||
|   }, [handleSave]) | ||||
| 
 | ||||
|   // 更新预览内容
 | ||||
|   useEffect(() => { | ||||
|     const updatePreview = async () => { | ||||
|       if (!value) { | ||||
|         setPreviewContent('') | ||||
|         return | ||||
|       } | ||||
|        | ||||
|       setIsConverting(true) | ||||
|       try { | ||||
|         const content = getPreviewContent() | ||||
|         setPreviewContent(content) | ||||
|          | ||||
|         // 等待下一个渲染周期,确保内容已更新到 DOM
 | ||||
|         await new Promise(resolve => requestAnimationFrame(resolve)) | ||||
|         // 重新初始化 Mermaid,但不阻塞界面
 | ||||
|         initializeMermaid().catch(error => { | ||||
|           console.error('Failed to initialize Mermaid:', error) | ||||
|           // 不显示错误提示,让界面继续运行
 | ||||
|         }) | ||||
|       } catch (error) { | ||||
|         console.error('Error updating preview:', error) | ||||
|         toast({ | ||||
|           variant: "destructive", | ||||
|           title: "预览更新失败", | ||||
|           description: "生成预览内容时发生错误", | ||||
|         }) | ||||
|       } finally { | ||||
|         setIsConverting(false) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     updatePreview() | ||||
|   }, [value, selectedTemplate, styleOptions, codeTheme, getPreviewContent, toast]) | ||||
| 
 | ||||
|   // 加载已保存的内容
 | ||||
|   useEffect(() => { | ||||
|     const draftContent = localStorage.getItem('wechat_editor_draft') | ||||
|     const savedContent = localStorage.getItem('wechat_editor_content') | ||||
|     setValue(newValue) | ||||
|     handleEditorChange(newValue) | ||||
|      | ||||
|     if (draftContent) { | ||||
|       setValue(draftContent) | ||||
|       setIsDraft(true) | ||||
|       toast({ | ||||
|         description: "已恢复未保存的草稿", | ||||
|         action: <ToastAction altText="放弃" onClick={handleDiscardDraft}>放弃草稿</ToastAction>, | ||||
|         duration: 5000, | ||||
|       }) | ||||
|     } else if (savedContent) { | ||||
|       setValue(savedContent) | ||||
|     } | ||||
|   }, [toast]) | ||||
|     requestAnimationFrame(() => { | ||||
|       if (textareaRef.current) { | ||||
|         textareaRef.current.scrollTop = currentPosition.scrollTop | ||||
|         textareaRef.current.setSelectionRange(currentPosition.start, currentPosition.end) | ||||
|       } | ||||
|     }) | ||||
|   }, [handleEditorChange]) | ||||
| 
 | ||||
|   const { handleCopy } = useCopy() | ||||
| 
 | ||||
|   // 处理复制
 | ||||
|   const onCopy = useCallback(async () => { | ||||
|     return handleCopy(window.getSelection(), previewContent) | ||||
|   }, [handleCopy, previewContent]) | ||||
| 
 | ||||
|   // 处理放弃草稿
 | ||||
|   const handleDiscardDraft = useCallback(() => { | ||||
|     const savedContent = localStorage.getItem('wechat_editor_content') | ||||
|     // 移除草稿
 | ||||
|     localStorage.removeItem('wechat_editor_draft') | ||||
|     // 恢复到最后保存的内容,如果没有则清空
 | ||||
|     setValue(savedContent || '') | ||||
|     setIsDraft(false) | ||||
|     toast({ | ||||
| @ -314,35 +117,6 @@ export default function WechatEditor() { | ||||
|     }) | ||||
|   }, [toast]) | ||||
| 
 | ||||
|   // 渲染预览内容
 | ||||
|   const renderPreview = useCallback(() => { | ||||
|     return ( | ||||
|       <div  | ||||
|         className="preview-content"  | ||||
|         dangerouslySetInnerHTML={{ __html: previewContent }} | ||||
|       /> | ||||
|     ) | ||||
|   }, [previewContent]) | ||||
| 
 | ||||
|   // 检测是否为移动设备
 | ||||
|   const isMobile = useCallback(() => { | ||||
|     if (typeof window === 'undefined') return false | ||||
|     return window.innerWidth < 640 | ||||
|   }, []) | ||||
| 
 | ||||
|   // 自动切换预览模式
 | ||||
|   useEffect(() => { | ||||
|     const handleResize = () => { | ||||
|       if (isMobile()) { | ||||
|         setPreviewSize('full') | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     handleResize() | ||||
|     window.addEventListener('resize', handleResize) | ||||
|     return () => window.removeEventListener('resize', handleResize) | ||||
|   }, [isMobile]) | ||||
| 
 | ||||
|   // 处理文章选择
 | ||||
|   const handleArticleSelect = useCallback((article: { content: string, template: string }) => { | ||||
|     setValue(article.content) | ||||
| @ -391,13 +165,11 @@ export default function WechatEditor() { | ||||
|     let newCursorPos = 0 | ||||
| 
 | ||||
|     if (options?.wrap && selectedText) { | ||||
|       // 如果有选中文本且需要包裹
 | ||||
|       newText = value.substring(0, start) +  | ||||
|                 text + selectedText + (options.suffix || text) +  | ||||
|                 value.substring(end) | ||||
|       newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length) | ||||
|     } else { | ||||
|       // 插入新文本
 | ||||
|       const insertText = selectedText || options?.placeholder || '' | ||||
|       newText = value.substring(0, start) +  | ||||
|                 text + insertText + (options?.suffix || '') +  | ||||
| @ -408,7 +180,6 @@ export default function WechatEditor() { | ||||
|     setValue(newText) | ||||
|     handleEditorChange(newText) | ||||
| 
 | ||||
|     // 恢复焦点并设置光标位置
 | ||||
|     requestAnimationFrame(() => { | ||||
|       textarea.focus() | ||||
|       textarea.setSelectionRange(newCursorPos, newCursorPos) | ||||
| @ -418,71 +189,9 @@ export default function WechatEditor() { | ||||
|   // 处理模版选择
 | ||||
|   const handleTemplateSelect = useCallback((templateId: string) => { | ||||
|     setSelectedTemplate(templateId) | ||||
|     // 重置样式设置为模版默认值
 | ||||
|     setStyleOptions({}) | ||||
|   }, []) | ||||
| 
 | ||||
|   // 更新字数和阅读时间
 | ||||
|   useEffect(() => { | ||||
|     const plainText = previewContent.replace(/<[^>]+>/g, '') | ||||
|     setWordCount(calculateWordCount(plainText)) | ||||
|     setReadingTime(calculateReadingTime(plainText)) | ||||
|   }, [previewContent]) | ||||
| 
 | ||||
|   const isScrolling = useRef<boolean>(false) | ||||
|   const scrollTimeout = useRef<NodeJS.Timeout>() | ||||
|   const lastScrollTop = useRef<number>(0) | ||||
| 
 | ||||
|   // 处理滚动同步
 | ||||
|   const handleEditorScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => { | ||||
|     // 如果是由于输入导致的滚动,不进行同步
 | ||||
|     if (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(() => { | ||||
|     if (window.confirm('确定要清除所有内容吗?')) { | ||||
| @ -496,25 +205,44 @@ export default function WechatEditor() { | ||||
|     } | ||||
|   }, [handleEditorChange, toast]) | ||||
| 
 | ||||
|   // 处理代码主题变化
 | ||||
|   const handleCodeThemeChange = useCallback((theme: CodeThemeId) => { | ||||
|     setCodeTheme(theme) | ||||
|     // 立即重新生成预览内容
 | ||||
|     setIsConverting(true) | ||||
|     try { | ||||
|       const content = getPreviewContent() | ||||
|       setPreviewContent(content) | ||||
|     } catch (error) { | ||||
|       console.error('Error updating preview:', error) | ||||
|       toast({ | ||||
|         variant: "destructive", | ||||
|         title: "预览更新失败", | ||||
|         description: "生成预览内容时发生错误", | ||||
|       }) | ||||
|     } finally { | ||||
|       setIsConverting(false) | ||||
|   // 检测是否为移动设备
 | ||||
|   const isMobile = useCallback(() => { | ||||
|     if (typeof window === 'undefined') return false | ||||
|     return window.innerWidth < 640 | ||||
|   }, []) | ||||
| 
 | ||||
|   // 自动切换预览模式
 | ||||
|   useEffect(() => { | ||||
|     const handleResize = () => { | ||||
|       if (isMobile()) { | ||||
|         setPreviewSize('full') | ||||
|       } | ||||
|     } | ||||
|   }, [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 ( | ||||
|     <div className="h-screen flex flex-col overflow-hidden"> | ||||
| @ -525,8 +253,8 @@ export default function WechatEditor() { | ||||
|           showPreview={showPreview} | ||||
|           selectedTemplate={selectedTemplate} | ||||
|           onSave={handleSave} | ||||
|           onCopy={handleCopy} | ||||
|           onCopyPreview={handleCopy} | ||||
|           onCopy={onCopy} | ||||
|           onCopyPreview={onCopy} | ||||
|           onNewArticle={handleNewArticle} | ||||
|           onArticleSelect={handleArticleSelect} | ||||
|           onTemplateSelect={handleTemplateSelect} | ||||
| @ -537,7 +265,7 @@ export default function WechatEditor() { | ||||
|           wordCount={wordCount} | ||||
|           readingTime={readingTime} | ||||
|           codeTheme={codeTheme} | ||||
|           onCodeThemeChange={handleCodeThemeChange} | ||||
|           onCodeThemeChange={setCodeTheme} | ||||
|         /> | ||||
|       </div> | ||||
|        | ||||
| @ -546,10 +274,17 @@ export default function WechatEditor() { | ||||
|         <div className="sm:hidden flex-1 flex flex-col"> | ||||
|           <div className="flex items-center justify-between p-2 border-b bg-background"> | ||||
|             <div className="flex-1 mr-2"> | ||||
|               <WechatStylePicker  | ||||
|                 value={selectedTemplate}  | ||||
|                 onSelect={handleTemplateSelect} | ||||
|               /> | ||||
|               <select | ||||
|                 value={selectedTemplate} | ||||
|                 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 className="flex items-center gap-2"> | ||||
|               <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" | ||||
|                 title="清除内容" | ||||
|               > | ||||
|                 <Trash2 className="h-3.5 w-3.5" /> | ||||
|                 清除 | ||||
|               </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" | ||||
|               > | ||||
|                 <Copy className="h-3.5 w-3.5" /> | ||||
|                 复制 | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|           <Tabs defaultValue="editor" className="flex-1 flex flex-col"> | ||||
|             <TabsList className="grid w-full grid-cols-2"> | ||||
|               <TabsTrigger value="editor">编辑</TabsTrigger> | ||||
|               <TabsTrigger value="preview">预览</TabsTrigger> | ||||
|             </TabsList> | ||||
|             <TabsContent value="editor" className="flex-1 data-[state=inactive]:hidden"> | ||||
|           <div className="flex-1 flex flex-col"> | ||||
|             <div className="flex-1"> | ||||
|               <div  | ||||
|                 ref={editorRef} | ||||
|                 className={cn( | ||||
| @ -587,27 +316,25 @@ export default function WechatEditor() { | ||||
|                   value={value} | ||||
|                   onChange={handleInput} | ||||
|                   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" | ||||
|                   placeholder="开始写作..." | ||||
|                   spellCheck={false} | ||||
|                   onScroll={handleEditorScroll} | ||||
|                 /> | ||||
|               </div> | ||||
|             </TabsContent> | ||||
|             <TabsContent value="preview" className="flex-1 data-[state=inactive]:hidden"> | ||||
|               <div className="h-full overflow-y-auto"> | ||||
|                 <EditorPreview  | ||||
|                   previewRef={previewRef} | ||||
|                   selectedTemplate={selectedTemplate} | ||||
|                   previewSize={previewSize} | ||||
|                   isConverting={isConverting} | ||||
|                   previewContent={previewContent} | ||||
|                   codeTheme={codeTheme} | ||||
|                   onPreviewSizeChange={setPreviewSize} | ||||
|                 /> | ||||
|               </div> | ||||
|             </TabsContent> | ||||
|           </Tabs> | ||||
|             </div> | ||||
|             <div className="flex-1"> | ||||
|               <EditorPreview  | ||||
|                 previewRef={previewRef} | ||||
|                 selectedTemplate={selectedTemplate} | ||||
|                 previewSize={previewSize} | ||||
|                 isConverting={isConverting} | ||||
|                 previewContent={previewContent} | ||||
|                 codeTheme={codeTheme} | ||||
|                 onPreviewSizeChange={setPreviewSize} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         {/* Desktop Split View */} | ||||
| @ -629,10 +356,10 @@ export default function WechatEditor() { | ||||
|                 value={value} | ||||
|                 onChange={handleInput} | ||||
|                 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" | ||||
|                 placeholder="开始写作..." | ||||
|                 spellCheck={false} | ||||
|                 onScroll={handleEditorScroll} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
| @ -651,14 +378,12 @@ export default function WechatEditor() { | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       {/* 底部工具栏 */} | ||||
|       {/* 底部状态栏 */} | ||||
|       <div className="fixed bottom-0 left-0 right-0 bg-background border-t h-10 flex items-center justify-end px-4 gap-4"> | ||||
|         <div className="flex items-center gap-2 text-sm text-muted-foreground"> | ||||
|           <Type className="h-4 w-4" /> | ||||
|           <span>{wordCount} 字</span> | ||||
|         </div> | ||||
|         <div className="flex items-center gap-2 text-sm text-muted-foreground mr-4"> | ||||
|           <Clock className="h-4 w-4" /> | ||||
|           <span>约 {readingTime}</span> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
							
								
								
									
										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 { PREVIEW_SIZES, type PreviewSize } from '../constants' | ||||
| import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react' | ||||
| import { templates } from '@/config/wechat-templates' | ||||
| import { useState, useRef, useEffect, useMemo } from 'react' | ||||
| 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 mermaid from 'mermaid' | ||||
| 
 | ||||
| interface EditorPreviewProps { | ||||
|   previewRef: React.RefObject<HTMLDivElement> | ||||
| @ -29,106 +32,133 @@ export function EditorPreview({ | ||||
|   const [zoom, setZoom] = useState(100) | ||||
|   const [isFullscreen, setIsFullscreen] = useState(false) | ||||
|   const isScrolling = useRef<boolean>(false) | ||||
|   const contentRef = useRef<string>('') | ||||
|   const renderTimeoutRef = useRef<number>() | ||||
|   const stableKeyRef = useRef(`preview-${Date.now()}`) | ||||
|   const { theme } = useTheme() | ||||
| 
 | ||||
|   // Add useEffect to handle content changes and Mermaid initialization
 | ||||
|   // 初始化 Mermaid
 | ||||
|   useEffect(() => { | ||||
|     if (!isConverting && previewContent) { | ||||
|       // Clear any pending render timeout
 | ||||
|       if (renderTimeoutRef.current) { | ||||
|         window.clearTimeout(renderTimeoutRef.current) | ||||
|     mermaid.initialize({ | ||||
|       theme: theme === 'dark' ? 'dark' : 'default', | ||||
|       startOnLoad: false, | ||||
|       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
 | ||||
|       renderTimeoutRef.current = window.setTimeout(() => { | ||||
|         requestAnimationFrame(() => { | ||||
|           initMermaid().catch(error => { | ||||
|             console.error('Failed to initialize mermaid:', error) | ||||
|           }) | ||||
|         }) | ||||
|       }, 100) // Wait for 100ms after last content change
 | ||||
|     } | ||||
|   // 使用 memo 包装预览内容
 | ||||
|   const PreviewContent = useMemo(() => { | ||||
|     return ( | ||||
|       <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]) | ||||
| 
 | ||||
|     // Cleanup timeout on unmount
 | ||||
|     return () => { | ||||
|       if (renderTimeoutRef.current) { | ||||
|         window.clearTimeout(renderTimeoutRef.current) | ||||
|       } | ||||
|     } | ||||
|   }, [isConverting, previewContent]) | ||||
| 
 | ||||
|   // Add useEffect to handle theme changes
 | ||||
|   // 渲染 Mermaid 图表
 | ||||
|   useEffect(() => { | ||||
|     if (document.querySelector('div.mermaid')) { | ||||
|       if (renderTimeoutRef.current) { | ||||
|         window.clearTimeout(renderTimeoutRef.current) | ||||
|       } | ||||
|     const renderMermaid = async () => { | ||||
|       try { | ||||
|         const elements = document.querySelectorAll('.mermaid') | ||||
|         if (!elements.length) return | ||||
| 
 | ||||
|       renderTimeoutRef.current = window.setTimeout(() => { | ||||
|         requestAnimationFrame(() => { | ||||
|           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) { | ||||
|         // 重新初始化所有图表
 | ||||
|         await Promise.all(Array.from(elements).map(async (element) => { | ||||
|           try { | ||||
|             // 创建一个临时的 div 来包含 SVG
 | ||||
|             const container = document.createElement('div') | ||||
|             container.appendChild(svgElement.cloneNode(true)) | ||||
|              | ||||
|             // 准备 HTML 和纯文本格式
 | ||||
|             const htmlContent = container.innerHTML | ||||
|             const plainText = mermaidElement.querySelector('.mermaid-source')?.textContent || '' | ||||
|             // 获取内容
 | ||||
|             const content = element.textContent?.trim() || '' | ||||
|             if (!content) return | ||||
| 
 | ||||
|             // 尝试复制为 HTML(保留图表效果)
 | ||||
|             await navigator.clipboard.write([ | ||||
|               new ClipboardItem({ | ||||
|                 'text/html': new Blob([htmlContent], { type: 'text/html' }), | ||||
|                 'text/plain': new Blob([plainText], { type: 'text/plain' }) | ||||
|               }) | ||||
|             ]) | ||||
|           } catch (error) { | ||||
|             // 如果复制 HTML 失败,退回到复制源代码
 | ||||
|             console.error('Failed to copy as HTML:', error) | ||||
|             const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || '' | ||||
|             if (e.clipboardData) { | ||||
|               e.clipboardData.setData('text/plain', sourceText) | ||||
|             // 清空容器
 | ||||
|             element.innerHTML = '' | ||||
|              | ||||
|             // 重新渲染
 | ||||
|             const { svg } = await mermaid.render( | ||||
|               `mermaid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, | ||||
|               content | ||||
|             ) | ||||
| 
 | ||||
|             // 更新内容
 | ||||
|             element.innerHTML = svg | ||||
| 
 | ||||
|             // 添加暗色模式支持
 | ||||
|             if (theme === 'dark') { | ||||
|               const svgElement = element.querySelector('svg') | ||||
|               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,退回到复制源代码
 | ||||
|           const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || '' | ||||
|           if (e.clipboardData) { | ||||
|             e.clipboardData.setData('text/plain', sourceText) | ||||
|           } | ||||
|         } | ||||
|         })) | ||||
|       } catch (error) { | ||||
|         console.error('Failed to initialize mermaid diagrams:', error) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     document.addEventListener('copy', handleCopy) | ||||
|     return () => document.removeEventListener('copy', handleCopy) | ||||
|     if (!isConverting) { | ||||
|       renderMermaid() | ||||
|     } | ||||
|   }, [previewContent, theme, isConverting]) | ||||
| 
 | ||||
|   // 监听全屏状态变化
 | ||||
|   useEffect(() => { | ||||
|     const handleFullscreenChange = () => { | ||||
|       setIsFullscreen(!!document.fullscreenElement) | ||||
|     } | ||||
|     document.addEventListener('fullscreenchange', handleFullscreenChange) | ||||
|     return () => { | ||||
|       document.removeEventListener('fullscreenchange', handleFullscreenChange) | ||||
|     } | ||||
|   }, []) | ||||
| 
 | ||||
|   const handleZoomIn = () => { | ||||
| @ -142,24 +172,11 @@ export function EditorPreview({ | ||||
|   const toggleFullscreen = () => { | ||||
|     if (!document.fullscreenElement) { | ||||
|       previewRef.current?.requestFullscreen() | ||||
|       setIsFullscreen(true) | ||||
|     } else { | ||||
|       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 ( | ||||
|     <div  | ||||
|       ref={previewRef} | ||||
| @ -170,7 +187,6 @@ export function EditorPreview({ | ||||
|         selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles, | ||||
|         `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="flex items-center gap-0.5 px-2"> | ||||
| @ -198,7 +214,7 @@ export function EditorPreview({ | ||||
|             <select | ||||
|               value={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 }]) => ( | ||||
|                 <option key={key} value={key}>{label}</option> | ||||
| @ -218,22 +234,25 @@ export function EditorPreview({ | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="flex-1 overflow-y-auto" onScroll={(e) => { | ||||
|         const container = e.currentTarget | ||||
|         const textarea = document.querySelector('.editor-container textarea') | ||||
|         if (!textarea || isScrolling.current) return | ||||
|         isScrolling.current = true | ||||
|       <div  | ||||
|         className="flex-1 overflow-y-auto" | ||||
|         onScroll={(e) => { | ||||
|           const container = e.currentTarget | ||||
|           const textarea = document.querySelector('.editor-container textarea') | ||||
|           if (!textarea || isScrolling.current) return | ||||
|           isScrolling.current = true | ||||
| 
 | ||||
|         try { | ||||
|           const scrollPercentage = container.scrollTop / (container.scrollHeight - container.clientHeight) | ||||
|           const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight) | ||||
|           textarea.scrollTop = textareaScrollTop | ||||
|         } finally { | ||||
|           requestAnimationFrame(() => { | ||||
|             isScrolling.current = false | ||||
|           }) | ||||
|         } | ||||
|       }}> | ||||
|           try { | ||||
|             const scrollPercentage = container.scrollTop / (container.scrollHeight - container.clientHeight) | ||||
|             const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight) | ||||
|             textarea.scrollTop = textareaScrollTop | ||||
|           } finally { | ||||
|             requestAnimationFrame(() => { | ||||
|               isScrolling.current = false | ||||
|             }) | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         <div className="h-full py-8 px-4"> | ||||
|           <div  | ||||
|             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 { | ||||
|  | ||||
							
								
								
									
										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
	 tianyaxiang
						tianyaxiang