From af3676672950ea53fff3a976e054fe7cea1b3d52 Mon Sep 17 00:00:00 2001 From: tianyaxiang Date: Mon, 3 Feb 2025 21:23:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 + src/components/editor/WechatEditor.tsx | 517 ++++-------------- .../editor/components/DesktopEditor.tsx | 1 + .../editor/components/EditorPreview.tsx | 253 +++++---- .../editor/components/EditorStatusBar.tsx | 1 + .../editor/components/MobileEditor.tsx | 1 + src/components/editor/hooks/useCopy.ts | 90 +++ .../editor/hooks/useEditorKeyboard.ts | 47 ++ .../editor/hooks/usePreviewContent.ts | 158 ++++++ src/components/editor/hooks/useScrollSync.ts | 49 ++ src/components/editor/hooks/useWordStats.ts | 27 + src/components/editor/utils/copy-handler.tsx | 266 --------- .../markdown/components/MermaidRenderer.tsx | 92 ++++ src/lib/markdown/config/mermaid.ts | 62 +++ src/lib/markdown/mermaid-utils.ts | 84 +++ src/lib/markdown/parser.ts | 76 +++ src/lib/markdown/types/mermaid.ts | 35 ++ src/lib/markdown/utils/mermaid.ts | 86 +++ 19 files changed, 1075 insertions(+), 779 deletions(-) create mode 100644 src/components/editor/components/DesktopEditor.tsx create mode 100644 src/components/editor/components/EditorStatusBar.tsx create mode 100644 src/components/editor/components/MobileEditor.tsx create mode 100644 src/components/editor/hooks/useCopy.ts create mode 100644 src/components/editor/hooks/useEditorKeyboard.ts create mode 100644 src/components/editor/hooks/usePreviewContent.ts create mode 100644 src/components/editor/hooks/useScrollSync.ts create mode 100644 src/components/editor/hooks/useWordStats.ts delete mode 100644 src/components/editor/utils/copy-handler.tsx create mode 100644 src/lib/markdown/components/MermaidRenderer.tsx create mode 100644 src/lib/markdown/config/mermaid.ts create mode 100644 src/lib/markdown/mermaid-utils.ts create mode 100644 src/lib/markdown/types/mermaid.ts create mode 100644 src/lib/markdown/utils/mermaid.ts diff --git a/package.json b/package.json index b6d411a..8a505e1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd7f4bb..6bdef44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/components/editor/WechatEditor.tsx b/src/components/editor/WechatEditor.tsx index 521257e..2e41318 100644 --- a/src/components/editor/WechatEditor.tsx +++ b/src/components/editor/WechatEditor.tsx @@ -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(null) const textareaRef = useRef(null) const previewRef = useRef(null) + + // 状态管理 const [value, setValue] = useState('') const [selectedTemplate, setSelectedTemplate] = useState('default') const [showPreview, setShowPreview] = useState(true) const [styleOptions, setStyleOptions] = useState({}) const [previewSize, setPreviewSize] = useState('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('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) => { - 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) => { - 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) => { + 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: 放弃草稿, - 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 ( -
- ) - }, [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(false) - const scrollTimeout = useRef() - const lastScrollTop = useRef(0) - - // 处理滚动同步 - const handleEditorScroll = useCallback((e: React.UIEvent) => { - // 如果是由于输入导致的滚动,不进行同步 - 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: 放弃草稿, + duration: 5000, + }) + } else if (savedContent) { + setValue(savedContent) + } + }, [toast, handleDiscardDraft]) + + const { wordCount, readingTime } = useWordStats(value) return (
@@ -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} />
@@ -546,10 +274,17 @@ export default function WechatEditor() {
- +
- - - 编辑 - 预览 - - +
+
- - -
- -
-
- +
+
+ +
+
{/* 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} />
@@ -651,14 +378,12 @@ export default function WechatEditor() { - {/* 底部工具栏 */} + {/* 底部状态栏 */}
- {wordCount} 字
- 约 {readingTime}
diff --git a/src/components/editor/components/DesktopEditor.tsx b/src/components/editor/components/DesktopEditor.tsx new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/components/editor/components/DesktopEditor.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/editor/components/EditorPreview.tsx b/src/components/editor/components/EditorPreview.tsx index 4b865d4..8697def 100644 --- a/src/components/editor/components/EditorPreview.tsx +++ b/src/components/editor/components/EditorPreview.tsx @@ -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 @@ -29,106 +32,133 @@ export function EditorPreview({ const [zoom, setZoom] = useState(100) const [isFullscreen, setIsFullscreen] = useState(false) const isScrolling = useRef(false) - const contentRef = useRef('') - const renderTimeoutRef = useRef() - 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 ( +
t.id === selectedTemplate)?.styles + )}> +
+
+ ) + }, [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 = ` +
+
+ Failed to render diagram +
+
+                  ${element.textContent || ''}
+                
+
+ ${error instanceof Error ? error.message : 'Unknown error'} +
+
+ ` } - } 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(() => ( -
t.id === selectedTemplate)?.styles - )}> -
-
- ), [previewContent, selectedTemplate]) - return (
t.id === selectedTemplate)?.styles, `code-theme-${codeTheme}` )} - key={stableKeyRef.current} >
@@ -198,7 +214,7 @@ export function EditorPreview({