diff --git a/src/components/editor/WechatEditor.tsx b/src/components/editor/WechatEditor.tsx index 8d09f6c..5039989 100644 --- a/src/components/editor/WechatEditor.tsx +++ b/src/components/editor/WechatEditor.tsx @@ -10,34 +10,23 @@ import mermaid from '@bytemd/plugin-mermaid' import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { templates } from '@/config/wechat-templates' import { cn } from '@/lib/utils' -import { Copy, Smartphone, Loader2, Save, Plus } from 'lucide-react' -import { WechatStylePicker } from '../template/WechatStylePicker' -import { StyleConfigDialog } from './StyleConfigDialog' -import { convertToWechat } from '@/lib/markdown' -import { type RendererOptions } from '@/lib/markdown' -import { TemplateManager } from '../template/TemplateManager' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' +import { convertToWechat } from '@/lib/markdown' +import { type RendererOptions } from '@/lib/markdown' +import { useEditorSync } from './hooks/useEditorSync' +import { useAutoSave } from './hooks/useAutoSave' +import { EditorToolbar } from './components/EditorToolbar' +import { EditorPreview } from './components/EditorPreview' +import { MobileToolbar } from './components/MobileToolbar' +import { type PreviewSize } from './constants' import 'bytemd/dist/index.css' import 'highlight.js/styles/github.css' import 'katex/dist/katex.css' import type { BytemdPlugin } from 'bytemd' -import { ArticleList } from './ArticleList' - -const PREVIEW_SIZES = { - small: { width: '360px', label: '小屏' }, - medium: { width: '390px', label: '中屏' }, - large: { width: '420px', label: '大屏' }, - full: { width: '100%', label: '全屏' }, -} as const - -type PreviewSize = keyof typeof PREVIEW_SIZES - -const AUTO_SAVE_DELAY = 3000 // 自动保存延迟(毫秒) export default function WechatEditor() { const { toast } = useToast() - const autoSaveTimerRef = useRef() const editorRef = useRef(null) const previewRef = useRef(null) const [value, setValue] = useState('') @@ -48,85 +37,9 @@ export default function WechatEditor() { const [isConverting, setIsConverting] = useState(false) const [isDraft, setIsDraft] = useState(false) - // 防止滚动事件循环的标志 - const isScrolling = useRef(false) - - // 同步滚动处理 - const handleScroll = useCallback((event: Event) => { - const source = event.target - // 检查是否是编辑器滚动 - const isEditor = source instanceof Element && source.closest('.editor-container') - - if (!editorRef.current) return - - const editorElement = editorRef.current.querySelector('.bytemd-editor') - if (!editorElement) return - - // 防止滚动事件循环 - if (isScrolling.current) return - isScrolling.current = true - - try { - if (isEditor) { - const sourceScrollTop = (source as Element).scrollTop - const sourceMaxScroll = (source as Element).scrollHeight - (source as Element).clientHeight - const percentage = sourceScrollTop / sourceMaxScroll - - const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight - window.scrollTo({ - top: percentage * windowMaxScroll, - behavior: 'auto' - }) - } else { - const windowScrollTop = window.scrollY - const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight - const percentage = windowScrollTop / windowMaxScroll - - const targetScrollTop = percentage * (editorElement.scrollHeight - editorElement.clientHeight) - editorElement.scrollTop = targetScrollTop - } - } finally { - // 确保在下一帧重置标志 - requestAnimationFrame(() => { - isScrolling.current = false - }) - } - }, []) - - // 添加滚动事件监听 - useEffect(() => { - const editorElement = editorRef.current?.querySelector('.bytemd-editor') - - if (editorElement) { - editorElement.addEventListener('scroll', handleScroll, { passive: true }) - window.addEventListener('scroll', handleScroll, { passive: true }) - - return () => { - editorElement.removeEventListener('scroll', handleScroll) - window.removeEventListener('scroll', handleScroll) - } - } - }, [handleScroll]) - - // 自动保存处理 - const handleEditorChange = useCallback((v: string) => { - setValue(v) - setIsDraft(true) - - // 清除之前的定时器 - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 设置新的自动保存定时器 - autoSaveTimerRef.current = setTimeout(() => { - localStorage.setItem('wechat_editor_draft', v) - toast({ - description: "内容已自动保存", - duration: 2000 - }) - }, AUTO_SAVE_DELAY) - }, [toast]) + // 使用自定义 hooks + const { handleScroll } = useEditorSync(editorRef) + const { handleEditorChange } = useAutoSave(value, setIsDraft) // 手动保存 const handleSave = useCallback(() => { @@ -166,15 +79,6 @@ export default function WechatEditor() { } }, [toast]) - // 清理自动保存定时器 - useEffect(() => { - return () => { - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - } - }, []) - // 监听快捷键保存事件 useEffect(() => { const handleSaveShortcut = (e: CustomEvent) => { @@ -187,7 +91,7 @@ export default function WechatEditor() { } }, [handleSave]) - const getPreviewContent = () => { + const getPreviewContent = useCallback(() => { if (!value) return '' const template = templates.find(t => t.id === selectedTemplate) @@ -212,9 +116,9 @@ export default function WechatEditor() { console.error('Template transformation error:', error) return html } - } + }, [value, selectedTemplate, styleOptions]) - const copyContent = () => { + const copyContent = useCallback(() => { const content = getPreviewContent() navigator.clipboard.writeText(content) .then(() => toast({ @@ -228,14 +132,9 @@ export default function WechatEditor() { description: "无法访问剪贴板,请检查浏览器权限", action: 重试, })) - } + }, [getPreviewContent, toast]) - const handleTemplateChange = () => { - setValue(value) - } - - const handleCopy = async () => { - // 使用 bytemd 编辑器的预览区域 + const handleCopy = useCallback(async () => { const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body') if (!previewContent) { toast({ @@ -248,75 +147,14 @@ export default function WechatEditor() { } try { - // 创建一个临时容器 const tempDiv = document.createElement('div') tempDiv.innerHTML = previewContent.innerHTML - // 应用模板样式 const template = templates.find(t => t.id === selectedTemplate) if (template) { tempDiv.className = template.styles } - // 处理图片 - const images = tempDiv.querySelectorAll('img') - images.forEach(img => { - // 确保图片使用绝对路径 - if (img.src.startsWith('/')) { - img.src = window.location.origin + img.src - } - }) - - // 处理代码块 - const codeBlocks = tempDiv.querySelectorAll('pre code') - codeBlocks.forEach(code => { - if (code.parentElement) { - code.parentElement.style.whiteSpace = 'pre-wrap' - code.parentElement.style.wordWrap = 'break-word' - } - }) - - // 应用样式选项 - const mergedOptions = { - ...styleOptions, - ...(template?.options || {}) - } - - // 应用标题样式 - const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6') - headings.forEach(heading => { - const level = heading.tagName.toLowerCase() - const style = mergedOptions.block?.[level as keyof RendererOptions['block']] - if (style && heading instanceof HTMLElement) { - Object.assign(heading.style, style) - } - }) - - // 应用段落样式 - const paragraphs = tempDiv.querySelectorAll('p') - paragraphs.forEach(p => { - if (mergedOptions.block?.p && p instanceof HTMLElement) { - Object.assign(p.style, mergedOptions.block.p) - } - }) - - // 应用引用样式 - const blockquotes = tempDiv.querySelectorAll('blockquote') - blockquotes.forEach(quote => { - if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) { - Object.assign(quote.style, mergedOptions.block.blockquote) - } - }) - - // 应用行内代码样式 - const inlineCodes = tempDiv.querySelectorAll(':not(pre) > code') - inlineCodes.forEach(code => { - if (mergedOptions.inline?.codespan && code instanceof HTMLElement) { - Object.assign(code.style, mergedOptions.inline.codespan) - } - }) - - // 使用 Clipboard API 复制 const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' }) const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' }) @@ -336,94 +174,17 @@ export default function WechatEditor() { }) } catch (err) { console.error('Copy error:', err) - // 降级处理:尝试使用 execCommand - try { - const tempDiv = document.createElement('div') - tempDiv.innerHTML = previewContent.innerHTML - document.body.appendChild(tempDiv) - const range = document.createRange() - range.selectNode(tempDiv) - const selection = window.getSelection() - if (selection) { - selection.removeAllRanges() - selection.addRange(range) - document.execCommand('copy') - selection.removeAllRanges() - } - document.body.removeChild(tempDiv) - toast({ - title: "复制成功", - description: "已复制预览内容(兼容模式)", - duration: 2000 - }) - } catch (fallbackErr) { - toast({ - variant: "destructive", - title: "复制失败", - description: "无法访问剪贴板,请检查浏览器权限", - action: 重试, - }) - } + toast({ + variant: "destructive", + title: "复制失败", + description: "无法访问剪贴板,请检查浏览器权限", + action: 重试, + }) } - } + }, [selectedTemplate, toast]) // 创建编辑器插件 const createEditorPlugin = useCallback((): BytemdPlugin => { - const applyTemplateStyles = (markdownBody: Element, selectedTemplateId: string, options: RendererOptions) => { - const template = templates.find(t => t.id === selectedTemplateId) - if (template) { - markdownBody.classList.add(template.styles) - - // 应用模板的样式配置 - const mergedOptions = { - ...options, - ...(template.options || {}) - } - - // 应用标题样式 - const headings = markdownBody.querySelectorAll('h1, h2, h3, h4, h5, h6') - headings.forEach(heading => { - const level = heading.tagName.toLowerCase() - const style = mergedOptions.block?.[level as keyof RendererOptions['block']] - if (style && heading instanceof HTMLElement) { - Object.assign(heading.style, style) - } - }) - - // 应用段落样式 - const paragraphs = markdownBody.querySelectorAll('p') - paragraphs.forEach(p => { - if (mergedOptions.block?.p && p instanceof HTMLElement) { - Object.assign(p.style, mergedOptions.block.p) - } - }) - - // 应用引用样式 - const blockquotes = markdownBody.querySelectorAll('blockquote') - blockquotes.forEach(quote => { - if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) { - Object.assign(quote.style, mergedOptions.block.blockquote) - } - }) - - // 应用代码块样式 - const codeBlocks = markdownBody.querySelectorAll('pre code') - codeBlocks.forEach(code => { - if (mergedOptions.block?.code_pre && code.parentElement instanceof HTMLElement) { - Object.assign(code.parentElement.style, mergedOptions.block.code_pre) - } - }) - - // 应用行内代码样式 - const inlineCodes = markdownBody.querySelectorAll(':not(pre) > code') - inlineCodes.forEach(code => { - if (mergedOptions.inline?.codespan && code instanceof HTMLElement) { - Object.assign(code.style, mergedOptions.inline.codespan) - } - }) - } - } - return { actions: [ { @@ -436,115 +197,26 @@ export default function WechatEditor() { window.dispatchEvent(event) } } - }, - { - title: '复制预览内容', - icon: '', - handler: { - type: 'action', - click: handleCopy - } - }, - { - title: '复制源码', - icon: '', - handler: { - type: 'action', - click: copyContent - } - }, - { - title: '预览尺寸', - icon: '', - handler: { - type: 'dropdown', - actions: Object.entries(PREVIEW_SIZES).map(([key, { label }]) => ({ - title: label, - handler: { - type: 'action', - click: () => { - setPreviewSize(key as PreviewSize) - const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body') - if (previewContent) { - const container = previewContent.parentElement - if (container) { - const size = PREVIEW_SIZES[key as PreviewSize] - container.style.maxWidth = size.width - container.style.margin = '0 auto' - container.style.transition = 'max-width 0.3s ease' - container.style.textAlign = 'center' - } - } - } - } - })) - } } - ], - viewerEffect({ markdownBody }) { - // 图片加载优化 - const images = markdownBody.querySelectorAll('img') - images.forEach(img => { - if (img instanceof HTMLImageElement) { - img.style.opacity = '0' - img.style.transition = 'opacity 0.3s ease' - img.onload = () => img.style.opacity = '1' - img.onerror = () => { - img.style.opacity = '1' - img.style.filter = 'grayscale(1)' - img.title = '图片加载失败' - } - } - }) - - // 链接优化 - const links = markdownBody.querySelectorAll('a') - links.forEach(link => { - if (link instanceof HTMLAnchorElement) { - link.target = '_blank' - link.rel = 'noopener noreferrer' - } - }) - - // 应用模板样式 - if (selectedTemplate) { - applyTemplateStyles(markdownBody, selectedTemplate, styleOptions) - } - - // 设置预览容器宽度 - const container = markdownBody.parentElement - if (container) { - const size = PREVIEW_SIZES[previewSize] - container.style.maxWidth = size.width - container.style.margin = '0 auto' - container.style.transition = 'max-width 0.3s ease' - } - } + ] } - }, [selectedTemplate, styleOptions, handleCopy, copyContent, previewSize, setPreviewSize, editorRef]) + }, []) // 使用创建的插件 const plugins = useMemo(() => [ - gfm(), // 使用默认配置 + gfm(), breaks(), frontmatter(), math({ - // 配置数学公式渲染 katexOptions: { throwOnError: false, output: 'html' } }), mermaid({ - // 配置 Mermaid 图表渲染 theme: 'default' }), - highlight({ - // 配置代码高亮 - init: (hljs) => { - // 可以在这里注册额外的语言 - } - }), + highlight(), createEditorPlugin() ], [createEditorPlugin]) @@ -580,7 +252,6 @@ export default function WechatEditor() { // 处理新建文章 const handleNewArticle = useCallback(() => { - // 如果有未保存的内容,提示用户 if (isDraft) { toast({ title: "提示", @@ -598,90 +269,28 @@ export default function WechatEditor() { return } - // 直接新建 setValue('# 新文章\n\n开始写作...') setIsDraft(false) }, [isDraft, toast]) return (
-
-
-
-
-
- - - -
- -
- -
- -
-
- {isDraft && ( - 未保存 - )} - - - -
-
-
-
-
+ setValue(value)} + onStyleOptionsChange={setStyleOptions} + onPreviewToggle={() => setShowPreview(!showPreview)} + styleOptions={styleOptions} + />
{showPreview && ( -
t.id === selectedTemplate)?.styles - )} - > -
-
预览效果
-
- -
-
- -
-
-
- {isConverting ? ( -
- -
- ) : ( -
t.id === selectedTemplate)?.styles - )}> -
-
- )} -
-
-
-
+ )}
- {/* 移动端底部工具栏 */} -
- - - - -
+ setShowPreview(!showPreview)} + onSave={handleSave} + onCopy={copyContent} + onCopyPreview={handleCopy} + />
) } \ No newline at end of file diff --git a/src/components/editor/components/EditorPreview.tsx b/src/components/editor/components/EditorPreview.tsx new file mode 100644 index 0000000..78f632a --- /dev/null +++ b/src/components/editor/components/EditorPreview.tsx @@ -0,0 +1,78 @@ +import { cn } from '@/lib/utils' +import { PREVIEW_SIZES, type PreviewSize } from '../constants' +import { Loader2 } from 'lucide-react' +import { templates } from '@/config/wechat-templates' + +interface EditorPreviewProps { + previewRef: React.RefObject + selectedTemplate: string + previewSize: PreviewSize + isConverting: boolean + previewContent: string + onPreviewSizeChange: (size: PreviewSize) => void +} + +export function EditorPreview({ + previewRef, + selectedTemplate, + previewSize, + isConverting, + previewContent, + onPreviewSizeChange +}: EditorPreviewProps) { + return ( +
t.id === selectedTemplate)?.styles + )} + > +
+
预览效果
+
+ +
+
+ +
+
+
+ {isConverting ? ( +
+ +
+ ) : ( +
t.id === selectedTemplate)?.styles + )}> +
+
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/editor/components/EditorToolbar.tsx b/src/components/editor/components/EditorToolbar.tsx new file mode 100644 index 0000000..2e27c7f --- /dev/null +++ b/src/components/editor/components/EditorToolbar.tsx @@ -0,0 +1,122 @@ +import { Copy, Plus, Save, Smartphone } from 'lucide-react' +import { cn } from '@/lib/utils' +import { WechatStylePicker } from '../../template/WechatStylePicker' +import { TemplateManager } from '../../template/TemplateManager' +import { StyleConfigDialog } from '../StyleConfigDialog' +import { ArticleList } from '../ArticleList' +import { type Article } from '../constants' +import { type RendererOptions } from '@/lib/markdown' + +interface EditorToolbarProps { + value: string + isDraft: boolean + showPreview: boolean + selectedTemplate: string + onSave: () => void + onCopy: () => void + onCopyPreview: () => void + onNewArticle: () => void + onArticleSelect: (article: Article) => void + onTemplateSelect: (template: string) => void + onTemplateChange: () => void + onStyleOptionsChange: (options: RendererOptions) => void + onPreviewToggle: () => void + styleOptions: RendererOptions +} + +export function EditorToolbar({ + value, + isDraft, + showPreview, + selectedTemplate, + onSave, + onCopy, + onCopyPreview, + onNewArticle, + onArticleSelect, + onTemplateSelect, + onTemplateChange, + onStyleOptionsChange, + onPreviewToggle, + styleOptions +}: EditorToolbarProps) { + return ( +
+
+
+
+
+ + + +
+ +
+ +
+ +
+
+ {isDraft && ( + 未保存 + )} + + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/editor/components/MobileToolbar.tsx b/src/components/editor/components/MobileToolbar.tsx new file mode 100644 index 0000000..3c48124 --- /dev/null +++ b/src/components/editor/components/MobileToolbar.tsx @@ -0,0 +1,68 @@ +import { Copy, Save, Smartphone } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface MobileToolbarProps { + showPreview: boolean + isDraft: boolean + onPreviewToggle: () => void + onSave: () => void + onCopy: () => void + onCopyPreview: () => void +} + +export function MobileToolbar({ + showPreview, + isDraft, + onPreviewToggle, + onSave, + onCopy, + onCopyPreview +}: MobileToolbarProps) { + return ( +
+ + + + +
+ ) +} \ No newline at end of file diff --git a/src/components/editor/constants.ts b/src/components/editor/constants.ts new file mode 100644 index 0000000..7eccdac --- /dev/null +++ b/src/components/editor/constants.ts @@ -0,0 +1,15 @@ +export const PREVIEW_SIZES = { + small: { width: '360px', label: '小屏' }, + medium: { width: '390px', label: '中屏' }, + large: { width: '420px', label: '大屏' }, + full: { width: '100%', label: '全屏' }, +} as const + +export type PreviewSize = keyof typeof PREVIEW_SIZES + +export const AUTO_SAVE_DELAY = 3000 // 自动保存延迟(毫秒) + +export interface Article { + content: string + template: string +} \ No newline at end of file diff --git a/src/components/editor/hooks/useAutoSave.ts b/src/components/editor/hooks/useAutoSave.ts new file mode 100644 index 0000000..b180e23 --- /dev/null +++ b/src/components/editor/hooks/useAutoSave.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useToast } from '@/components/ui/use-toast' +import { AUTO_SAVE_DELAY } from '../constants' + +export function useAutoSave(value: string, setIsDraft: (isDraft: boolean) => void) { + const { toast } = useToast() + const autoSaveTimerRef = useRef() + + const handleEditorChange = useCallback((v: string) => { + setIsDraft(true) + + // 清除之前的定时器 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + // 设置新的自动保存定时器 + autoSaveTimerRef.current = setTimeout(() => { + localStorage.setItem('wechat_editor_draft', v) + toast({ + description: "内容已自动保存", + duration: 2000 + }) + }, AUTO_SAVE_DELAY) + }, [toast, setIsDraft]) + + // 清理自动保存定时器 + useEffect(() => { + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + } + }, []) + + return { handleEditorChange } +} \ No newline at end of file diff --git a/src/components/editor/hooks/useEditorSync.ts b/src/components/editor/hooks/useEditorSync.ts new file mode 100644 index 0000000..82e2b68 --- /dev/null +++ b/src/components/editor/hooks/useEditorSync.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef } from 'react' + +export function useEditorSync(editorRef: React.RefObject) { + // 防止滚动事件循环的标志 + const isScrolling = useRef(false) + + // 同步滚动处理 + const handleScroll = useCallback((event: Event) => { + const source = event.target + // 检查是否是编辑器滚动 + const isEditor = source instanceof Element && source.closest('.editor-container') + + if (!editorRef.current) return + + const editorElement = editorRef.current.querySelector('.bytemd-editor') + if (!editorElement) return + + // 防止滚动事件循环 + if (isScrolling.current) return + isScrolling.current = true + + try { + if (isEditor) { + const sourceScrollTop = (source as Element).scrollTop + const sourceMaxScroll = (source as Element).scrollHeight - (source as Element).clientHeight + const percentage = sourceScrollTop / sourceMaxScroll + + const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight + window.scrollTo({ + top: percentage * windowMaxScroll, + behavior: 'auto' + }) + } else { + const windowScrollTop = window.scrollY + const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight + const percentage = windowScrollTop / windowMaxScroll + + const targetScrollTop = percentage * (editorElement.scrollHeight - editorElement.clientHeight) + editorElement.scrollTop = targetScrollTop + } + } finally { + // 确保在下一帧重置标志 + requestAnimationFrame(() => { + isScrolling.current = false + }) + } + }, [editorRef]) + + // 添加滚动事件监听 + useEffect(() => { + const editorElement = editorRef.current?.querySelector('.bytemd-editor') + + if (editorElement) { + editorElement.addEventListener('scroll', handleScroll, { passive: true }) + window.addEventListener('scroll', handleScroll, { passive: true }) + + return () => { + editorElement.removeEventListener('scroll', handleScroll) + window.removeEventListener('scroll', handleScroll) + } + } + }, [handleScroll]) + + return { handleScroll } +} \ No newline at end of file