735 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			735 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
'use client'
 | 
						|
 | 
						|
import { useState, useCallback, useEffect, useRef } from 'react'
 | 
						|
import { templates } from '@/config/wechat-templates'
 | 
						|
import { cn } from '@/lib/utils'
 | 
						|
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 { 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 } from 'lucide-react'
 | 
						|
 | 
						|
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>('creative')
 | 
						|
  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 })
 | 
						|
 | 
						|
  // 使用自定义 hooks
 | 
						|
  const { handleScroll } = useEditorSync(editorRef)
 | 
						|
  const { handleEditorChange } = useAutoSave(value, setIsDraft)
 | 
						|
 | 
						|
  // 处理编辑器输入
 | 
						|
  const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
 | 
						|
    const newValue = e.target.value
 | 
						|
    setValue(newValue)
 | 
						|
    handleEditorChange(newValue)
 | 
						|
    // 保存光标位置
 | 
						|
    setCursorPosition({
 | 
						|
      start: e.target.selectionStart,
 | 
						|
      end: e.target.selectionEnd
 | 
						|
    })
 | 
						|
  }, [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 || {}),
 | 
						|
        // 确保标题使用主题颜色和正确的字体大小
 | 
						|
        h1: { 
 | 
						|
          fontSize: styleOptions.block?.h1?.fontSize || '24px',
 | 
						|
          color: styleOptions.base?.themeColor || '#1a1a1a'
 | 
						|
        },
 | 
						|
        h2: { 
 | 
						|
          fontSize: styleOptions.block?.h2?.fontSize || '20px',
 | 
						|
          color: styleOptions.base?.themeColor || '#1a1a1a'
 | 
						|
        },
 | 
						|
        h3: { 
 | 
						|
          fontSize: styleOptions.block?.h3?.fontSize || '18px',
 | 
						|
          color: styleOptions.base?.themeColor || '#1a1a1a'
 | 
						|
        }
 | 
						|
      },
 | 
						|
      inline: {
 | 
						|
        ...(template?.options?.inline || {}),
 | 
						|
        ...(styleOptions.inline || {})
 | 
						|
      }
 | 
						|
    }
 | 
						|
    
 | 
						|
    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])
 | 
						|
 | 
						|
  // 手动保存
 | 
						|
  const handleSave = useCallback(() => {
 | 
						|
    try {
 | 
						|
      localStorage.setItem('wechat_editor_content', value)
 | 
						|
      setIsDraft(false)
 | 
						|
      toast({
 | 
						|
        title: "保存成功",
 | 
						|
        description: "内容已保存到本地",
 | 
						|
        duration: 3000
 | 
						|
      })
 | 
						|
    } catch (error) {
 | 
						|
      toast({
 | 
						|
        variant: "destructive",
 | 
						|
        title: "保存失败",
 | 
						|
        description: "无法保存内容,请检查浏览器存储空间",
 | 
						|
        action: <ToastAction altText="重试">重试</ToastAction>,
 | 
						|
      })
 | 
						|
    }
 | 
						|
  }, [value, toast])
 | 
						|
 | 
						|
  // 监听快捷键保存事件
 | 
						|
  useEffect(() => {
 | 
						|
    const handleKeyDown = (e: KeyboardEvent) => {
 | 
						|
      if ((e.metaKey || e.ctrlKey) && e.key === 's') {
 | 
						|
        e.preventDefault()
 | 
						|
        handleSave()
 | 
						|
      }
 | 
						|
    }
 | 
						|
    
 | 
						|
    window.addEventListener('keydown', handleKeyDown)
 | 
						|
    return () => window.removeEventListener('keydown', handleKeyDown)
 | 
						|
  }, [handleSave])
 | 
						|
 | 
						|
  // 延迟更新预览内容
 | 
						|
  useEffect(() => {
 | 
						|
    if (!showPreview) return
 | 
						|
    
 | 
						|
    setIsConverting(true)
 | 
						|
    const updatePreview = async () => {
 | 
						|
      try {
 | 
						|
        const content = getPreviewContent()
 | 
						|
        setPreviewContent(content)
 | 
						|
      } finally {
 | 
						|
        setIsConverting(false)
 | 
						|
      }
 | 
						|
    }
 | 
						|
    updatePreview()
 | 
						|
  }, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent])
 | 
						|
 | 
						|
  // 加载已保存的内容
 | 
						|
  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="放弃">放弃草稿</ToastAction>,
 | 
						|
        duration: 5000,
 | 
						|
      })
 | 
						|
    } else if (savedContent) {
 | 
						|
      setValue(savedContent)
 | 
						|
    }
 | 
						|
  }, [toast])
 | 
						|
 | 
						|
  const handleCopy = useCallback(async () => {
 | 
						|
    const previewContent = previewRef.current?.querySelector('.preview-content') as HTMLElement | null
 | 
						|
    if (!previewContent) {
 | 
						|
      toast({
 | 
						|
        variant: "destructive",
 | 
						|
        title: "复制失败",
 | 
						|
        description: "未找到预览内容",
 | 
						|
        duration: 2000
 | 
						|
      })
 | 
						|
      return
 | 
						|
    }
 | 
						|
 | 
						|
    try {
 | 
						|
      const tempDiv = document.createElement('div')
 | 
						|
      tempDiv.innerHTML = previewContent.innerHTML
 | 
						|
 | 
						|
      const template = templates.find(t => t.id === selectedTemplate)
 | 
						|
      if (template) {
 | 
						|
        // 添加基础样式容器
 | 
						|
        const styleContainer = document.createElement('div')
 | 
						|
        styleContainer.style.cssText = `
 | 
						|
          max-width: 780px;
 | 
						|
          margin: 0 auto;
 | 
						|
          padding: 20px;
 | 
						|
          box-sizing: border-box;
 | 
						|
        `
 | 
						|
        styleContainer.innerHTML = tempDiv.innerHTML
 | 
						|
        tempDiv.innerHTML = ''
 | 
						|
        tempDiv.appendChild(styleContainer)
 | 
						|
 | 
						|
        // 处理 CSS 变量
 | 
						|
        const cssVariables = {
 | 
						|
          '--md-primary-color': styleOptions.base?.primaryColor || template.options.base?.primaryColor || '#333333',
 | 
						|
          '--foreground': styleOptions.base?.themeColor || template.options.base?.themeColor || '#1a1a1a',
 | 
						|
          '--background': '#ffffff',
 | 
						|
          '--muted': '#f1f5f9',
 | 
						|
          '--muted-foreground': '#64748b',
 | 
						|
          '--border': '#e2e8f0',
 | 
						|
          '--ring': '#3b82f6',
 | 
						|
          '--accent': '#f8fafc',
 | 
						|
          '--accent-foreground': '#0f172a'
 | 
						|
        }
 | 
						|
 | 
						|
        // 替换 CSS 变量的函数
 | 
						|
        const replaceCSSVariables = (value: string) => {
 | 
						|
          let result = value
 | 
						|
          Object.entries(cssVariables).forEach(([variable, replaceValue]) => {
 | 
						|
            // 处理 var(--xxx) 格式
 | 
						|
            result = result.replace(
 | 
						|
              new RegExp(`var\\(${variable}\\)`, 'g'),
 | 
						|
              replaceValue
 | 
						|
            )
 | 
						|
            // 处理 hsl(var(--xxx)) 格式
 | 
						|
            result = result.replace(
 | 
						|
              new RegExp(`hsl\\(var\\(${variable}\\)\\)`, 'g'),
 | 
						|
              replaceValue
 | 
						|
            )
 | 
						|
            // 处理 rgb(var(--xxx)) 格式
 | 
						|
            result = result.replace(
 | 
						|
              new RegExp(`rgb\\(var\\(${variable}\\)\\)`, 'g'),
 | 
						|
              replaceValue
 | 
						|
            )
 | 
						|
          })
 | 
						|
          return result
 | 
						|
        }
 | 
						|
 | 
						|
        // 合并基础样式
 | 
						|
        const baseStyles = {
 | 
						|
          fontSize: template.options.base?.fontSize || '15px',
 | 
						|
          lineHeight: template.options.base?.lineHeight || '1.75',
 | 
						|
          textAlign: template.options.base?.textAlign || 'left',
 | 
						|
          color: template.options.base?.themeColor || '#1a1a1a',
 | 
						|
          fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
 | 
						|
          ...(styleOptions.base || {})
 | 
						|
        }
 | 
						|
 | 
						|
        // 处理基础样式中的 CSS 变量
 | 
						|
        Object.entries(baseStyles).forEach(([key, value]) => {
 | 
						|
          if (typeof value === 'string') {
 | 
						|
            baseStyles[key] = replaceCSSVariables(value)
 | 
						|
          }
 | 
						|
        })
 | 
						|
        Object.assign(styleContainer.style, baseStyles)
 | 
						|
 | 
						|
        // 应用基础样式到所有文本元素
 | 
						|
        const applyBaseStyles = () => {
 | 
						|
          const textElements = styleContainer.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, li, code, strong, em, a, span')
 | 
						|
          textElements.forEach(element => {
 | 
						|
            if (element instanceof HTMLElement) {
 | 
						|
              // 如果元素没有特定的字体和字号设置,应用基础样式
 | 
						|
              if (!element.style.fontFamily) {
 | 
						|
                element.style.fontFamily = baseStyles.fontFamily
 | 
						|
              }
 | 
						|
              if (!element.style.fontSize) {
 | 
						|
                // 根据元素类型设置不同的字号
 | 
						|
                const tagName = element.tagName.toLowerCase()
 | 
						|
                if (tagName === 'h1') {
 | 
						|
                  element.style.fontSize = '24px'
 | 
						|
                } else if (tagName === 'h2') {
 | 
						|
                  element.style.fontSize = '20px'
 | 
						|
                } else if (tagName === 'h3') {
 | 
						|
                  element.style.fontSize = '18px'
 | 
						|
                } else if (tagName === 'code') {
 | 
						|
                  element.style.fontSize = '14px'
 | 
						|
                } else {
 | 
						|
                  element.style.fontSize = baseStyles.fontSize
 | 
						|
                }
 | 
						|
              }
 | 
						|
              // 确保颜色和行高也被应用
 | 
						|
              if (!element.style.color) {
 | 
						|
                element.style.color = baseStyles.color
 | 
						|
              }
 | 
						|
              if (!element.style.lineHeight) {
 | 
						|
                element.style.lineHeight = baseStyles.lineHeight as string
 | 
						|
              }
 | 
						|
            }
 | 
						|
          })
 | 
						|
        }
 | 
						|
 | 
						|
        // 合并块级样式
 | 
						|
        const applyBlockStyles = (selector: string, templateStyles: any, customStyles: any) => {
 | 
						|
          styleContainer.querySelectorAll(selector).forEach(element => {
 | 
						|
            const effectiveStyles: Record<string, string> = {}
 | 
						|
 | 
						|
            // 应用模板样式
 | 
						|
            if (templateStyles) {
 | 
						|
              Object.entries(templateStyles).forEach(([key, value]) => {
 | 
						|
                if (typeof value === 'string') {
 | 
						|
                  effectiveStyles[key] = replaceCSSVariables(value)
 | 
						|
                } else {
 | 
						|
                  effectiveStyles[key] = value as string
 | 
						|
                }
 | 
						|
              })
 | 
						|
            }
 | 
						|
 | 
						|
            // 应用自定义样式
 | 
						|
            if (customStyles) {
 | 
						|
              Object.entries(customStyles).forEach(([key, value]) => {
 | 
						|
                if (typeof value === 'string') {
 | 
						|
                  effectiveStyles[key] = replaceCSSVariables(value)
 | 
						|
                } else {
 | 
						|
                  effectiveStyles[key] = value as string
 | 
						|
                }
 | 
						|
              })
 | 
						|
            }
 | 
						|
 | 
						|
            // 确保字体样式被应用
 | 
						|
            if (!effectiveStyles.fontFamily) {
 | 
						|
              effectiveStyles.fontFamily = baseStyles.fontFamily
 | 
						|
            }
 | 
						|
            // 确保字号被应用
 | 
						|
            if (!effectiveStyles.fontSize) {
 | 
						|
              const tagName = (element as HTMLElement).tagName.toLowerCase()
 | 
						|
              if (tagName === 'h1') {
 | 
						|
                effectiveStyles.fontSize = '24px'
 | 
						|
              } else if (tagName === 'h2') {
 | 
						|
                effectiveStyles.fontSize = '20px'
 | 
						|
              } else if (tagName === 'h3') {
 | 
						|
                effectiveStyles.fontSize = '18px'
 | 
						|
              } else if (tagName === 'code') {
 | 
						|
                effectiveStyles.fontSize = '14px'
 | 
						|
              } else {
 | 
						|
                effectiveStyles.fontSize = baseStyles.fontSize
 | 
						|
              }
 | 
						|
            }
 | 
						|
 | 
						|
            Object.assign((element as HTMLElement).style, effectiveStyles)
 | 
						|
          })
 | 
						|
        }
 | 
						|
 | 
						|
        // 应用标题样式
 | 
						|
        ;['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach(tag => {
 | 
						|
          applyBlockStyles(
 | 
						|
            tag,
 | 
						|
            template.options.block?.[tag],
 | 
						|
            styleOptions.block?.[tag]
 | 
						|
          )
 | 
						|
        })
 | 
						|
 | 
						|
        // 应用段落样式
 | 
						|
        applyBlockStyles(
 | 
						|
          'p',
 | 
						|
          template.options.block?.p,
 | 
						|
          styleOptions.block?.p
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用引用样式
 | 
						|
        applyBlockStyles(
 | 
						|
          'blockquote',
 | 
						|
          template.options.block?.blockquote,
 | 
						|
          styleOptions.block?.blockquote
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用代码块样式
 | 
						|
        applyBlockStyles(
 | 
						|
          'pre',
 | 
						|
          template.options.block?.code_pre,
 | 
						|
          styleOptions.block?.code_pre
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用图片样式
 | 
						|
        applyBlockStyles(
 | 
						|
          'img',
 | 
						|
          template.options.block?.image,
 | 
						|
          styleOptions.block?.image
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用列表样式
 | 
						|
        applyBlockStyles(
 | 
						|
          'ul',
 | 
						|
          template.options.block?.ul,
 | 
						|
          styleOptions.block?.ul
 | 
						|
        )
 | 
						|
        applyBlockStyles(
 | 
						|
          'ol',
 | 
						|
          template.options.block?.ol,
 | 
						|
          styleOptions.block?.ol
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用内联样式
 | 
						|
        const applyInlineStyles = (selector: string, templateStyles: any, customStyles: any) => {
 | 
						|
          styleContainer.querySelectorAll(selector).forEach(element => {
 | 
						|
            const effectiveStyles: Record<string, string> = {}
 | 
						|
 | 
						|
            // 应用模板样式
 | 
						|
            if (templateStyles) {
 | 
						|
              Object.entries(templateStyles).forEach(([key, value]) => {
 | 
						|
                if (typeof value === 'string') {
 | 
						|
                  effectiveStyles[key] = replaceCSSVariables(value)
 | 
						|
                } else {
 | 
						|
                  effectiveStyles[key] = value as string
 | 
						|
                }
 | 
						|
              })
 | 
						|
            }
 | 
						|
 | 
						|
            // 应用自定义样式
 | 
						|
            if (customStyles) {
 | 
						|
              Object.entries(customStyles).forEach(([key, value]) => {
 | 
						|
                if (typeof value === 'string') {
 | 
						|
                  effectiveStyles[key] = replaceCSSVariables(value)
 | 
						|
                } else {
 | 
						|
                  effectiveStyles[key] = value as string
 | 
						|
                }
 | 
						|
              })
 | 
						|
            }
 | 
						|
 | 
						|
            Object.assign((element as HTMLElement).style, effectiveStyles)
 | 
						|
          })
 | 
						|
        }
 | 
						|
 | 
						|
        // 应用加粗样式
 | 
						|
        applyInlineStyles(
 | 
						|
          'strong',
 | 
						|
          template.options.inline?.strong,
 | 
						|
          styleOptions.inline?.strong
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用斜体样式
 | 
						|
        applyInlineStyles(
 | 
						|
          'em',
 | 
						|
          template.options.inline?.em,
 | 
						|
          styleOptions.inline?.em
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用行内代码样式
 | 
						|
        applyInlineStyles(
 | 
						|
          'code:not(pre code)',
 | 
						|
          template.options.inline?.codespan,
 | 
						|
          styleOptions.inline?.codespan
 | 
						|
        )
 | 
						|
 | 
						|
        // 应用链接样式
 | 
						|
        applyInlineStyles(
 | 
						|
          'a',
 | 
						|
          template.options.inline?.link,
 | 
						|
          styleOptions.inline?.link
 | 
						|
        )
 | 
						|
 | 
						|
        // 在应用所有样式后,确保基础样式被正确应用
 | 
						|
        applyBaseStyles()
 | 
						|
      }
 | 
						|
 | 
						|
      const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
 | 
						|
      const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' })
 | 
						|
 | 
						|
      await navigator.clipboard.write([
 | 
						|
        new ClipboardItem({
 | 
						|
          'text/html': htmlBlob,
 | 
						|
          'text/plain': textBlob
 | 
						|
        })
 | 
						|
      ])
 | 
						|
 | 
						|
      toast({
 | 
						|
        title: "复制成功",
 | 
						|
        description: template 
 | 
						|
          ? "已复制预览内容(包含样式)" 
 | 
						|
          : "已复制预览内容",
 | 
						|
        duration: 2000
 | 
						|
      })
 | 
						|
    } catch (err) {
 | 
						|
      console.error('Copy error:', err)
 | 
						|
      try {
 | 
						|
        await navigator.clipboard.writeText(previewContent.innerText)
 | 
						|
        toast({
 | 
						|
          title: "复制成功",
 | 
						|
          description: "已复制预览内容(仅文本)",
 | 
						|
          duration: 2000
 | 
						|
        })
 | 
						|
      } catch (fallbackErr) {
 | 
						|
        toast({
 | 
						|
          variant: "destructive",
 | 
						|
          title: "复制失败",
 | 
						|
          description: "无法访问剪贴板,请检查浏览器权限",
 | 
						|
          action: <ToastAction altText="重试">重试</ToastAction>,
 | 
						|
        })
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }, [previewRef, selectedTemplate, styleOptions, toast])
 | 
						|
 | 
						|
  // 检测是否为移动设备
 | 
						|
  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)
 | 
						|
    setSelectedTemplate(article.template)
 | 
						|
    setIsDraft(false)
 | 
						|
    toast({
 | 
						|
      title: "加载成功",
 | 
						|
      description: "已加载选中的文章",
 | 
						|
      duration: 2000
 | 
						|
    })
 | 
						|
  }, [toast])
 | 
						|
 | 
						|
  // 处理新建文章
 | 
						|
  const handleNewArticle = useCallback(() => {
 | 
						|
    if (isDraft) {
 | 
						|
      toast({
 | 
						|
        title: "提示",
 | 
						|
        description: "当前文章未保存,是否继续?",
 | 
						|
        action: (
 | 
						|
          <ToastAction altText="继续" onClick={() => {
 | 
						|
            setValue('# 新文章\n\n开始写作...')
 | 
						|
            setIsDraft(false)
 | 
						|
          }}>
 | 
						|
            继续
 | 
						|
          </ToastAction>
 | 
						|
        ),
 | 
						|
        duration: 5000,
 | 
						|
      })
 | 
						|
      return
 | 
						|
    }
 | 
						|
 | 
						|
    setValue('# 新文章\n\n开始写作...')
 | 
						|
    setIsDraft(false)
 | 
						|
  }, [isDraft, toast])
 | 
						|
 | 
						|
  // 处理工具栏插入文本
 | 
						|
  const handleToolbarInsert = useCallback((text: string, options?: { wrap?: boolean; placeholder?: string; suffix?: string }) => {
 | 
						|
    const textarea = textareaRef.current
 | 
						|
    if (!textarea) return
 | 
						|
 | 
						|
    const start = textarea.selectionStart
 | 
						|
    const end = textarea.selectionEnd
 | 
						|
    const selectedText = value.substring(start, end)
 | 
						|
    
 | 
						|
    let newText = ''
 | 
						|
    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 || '') + 
 | 
						|
                value.substring(end)
 | 
						|
      newCursorPos = start + text.length + insertText.length + (options?.suffix?.length || 0)
 | 
						|
    }
 | 
						|
 | 
						|
    setValue(newText)
 | 
						|
    handleEditorChange(newText)
 | 
						|
 | 
						|
    // 恢复焦点并设置光标位置
 | 
						|
    requestAnimationFrame(() => {
 | 
						|
      textarea.focus()
 | 
						|
      textarea.setSelectionRange(newCursorPos, newCursorPos)
 | 
						|
    })
 | 
						|
  }, [value, handleEditorChange])
 | 
						|
 | 
						|
  return (
 | 
						|
    <div className="h-full flex flex-col">
 | 
						|
      <div className="hidden sm:block">
 | 
						|
        <EditorToolbar 
 | 
						|
          value={value}
 | 
						|
          isDraft={isDraft}
 | 
						|
          showPreview={showPreview}
 | 
						|
          selectedTemplate={selectedTemplate}
 | 
						|
          onSave={handleSave}
 | 
						|
          onCopy={handleCopy}
 | 
						|
          onCopyPreview={handleCopy}
 | 
						|
          onNewArticle={handleNewArticle}
 | 
						|
          onArticleSelect={handleArticleSelect}
 | 
						|
          onTemplateSelect={setSelectedTemplate}
 | 
						|
          onTemplateChange={() => setValue(value)}
 | 
						|
          onStyleOptionsChange={setStyleOptions}
 | 
						|
          onPreviewToggle={() => setShowPreview(!showPreview)}
 | 
						|
          styleOptions={styleOptions}
 | 
						|
        />
 | 
						|
      </div>
 | 
						|
      
 | 
						|
      <div className="flex-1 flex flex-col sm:flex-row overflow-hidden">
 | 
						|
        {/* Mobile View */}
 | 
						|
        <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={setSelectedTemplate}
 | 
						|
              />
 | 
						|
            </div>
 | 
						|
            <button
 | 
						|
              onClick={() => {
 | 
						|
                handleCopy()
 | 
						|
                  .then(() => {
 | 
						|
                    toast({
 | 
						|
                      title: "复制成功",
 | 
						|
                      description: "已复制预览内容到剪贴板",
 | 
						|
                      duration: 2000
 | 
						|
                    })
 | 
						|
                  })
 | 
						|
                  .catch(() => {
 | 
						|
                    toast({
 | 
						|
                      variant: "destructive",
 | 
						|
                      title: "复制失败",
 | 
						|
                      description: "无法访问剪贴板,请检查浏览器权限",
 | 
						|
                      duration: 2000
 | 
						|
                    })
 | 
						|
                  })
 | 
						|
              }}
 | 
						|
              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>
 | 
						|
          <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 
 | 
						|
                ref={editorRef}
 | 
						|
                className={cn(
 | 
						|
                  "h-full overflow-y-auto",
 | 
						|
                  selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
 | 
						|
                )}
 | 
						|
              >
 | 
						|
                <textarea
 | 
						|
                  ref={textareaRef}
 | 
						|
                  value={value}
 | 
						|
                  onChange={handleInput}
 | 
						|
                  onKeyDown={handleKeyDown}
 | 
						|
                  className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed"
 | 
						|
                  placeholder="开始写作..."
 | 
						|
                  spellCheck={false}
 | 
						|
                />
 | 
						|
              </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}
 | 
						|
                  onPreviewSizeChange={setPreviewSize}
 | 
						|
                />
 | 
						|
              </div>
 | 
						|
            </TabsContent>
 | 
						|
          </Tabs>
 | 
						|
        </div>
 | 
						|
 | 
						|
        {/* Desktop Split View */}
 | 
						|
        <div className="hidden sm:flex flex-1 flex-row">
 | 
						|
          <div 
 | 
						|
            ref={editorRef}
 | 
						|
            className={cn(
 | 
						|
              "editor-container bg-background transition-all duration-300 ease-in-out flex flex-col",
 | 
						|
              showPreview 
 | 
						|
                ? "h-full w-1/2 border-r" 
 | 
						|
                : "h-full w-full",
 | 
						|
              selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
 | 
						|
            )}
 | 
						|
          >
 | 
						|
            <MarkdownToolbar onInsert={handleToolbarInsert} />
 | 
						|
            <div className="flex-1">
 | 
						|
              <textarea
 | 
						|
                ref={textareaRef}
 | 
						|
                value={value}
 | 
						|
                onChange={handleInput}
 | 
						|
                onKeyDown={handleKeyDown}
 | 
						|
                className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed"
 | 
						|
                placeholder="开始写作..."
 | 
						|
                spellCheck={false}
 | 
						|
              />
 | 
						|
            </div>
 | 
						|
          </div>
 | 
						|
          
 | 
						|
          {showPreview && (
 | 
						|
            <EditorPreview 
 | 
						|
              previewRef={previewRef}
 | 
						|
              selectedTemplate={selectedTemplate}
 | 
						|
              previewSize={previewSize}
 | 
						|
              isConverting={isConverting}
 | 
						|
              previewContent={previewContent}
 | 
						|
              onPreviewSizeChange={setPreviewSize}
 | 
						|
            />
 | 
						|
          )}
 | 
						|
        </div>
 | 
						|
      </div>
 | 
						|
    </div>
 | 
						|
  )
 | 
						|
} |