重构代码

This commit is contained in:
tianyaxiang 2025-02-03 22:37:57 +08:00
parent af36766729
commit 13fa3ea75c
7 changed files with 434 additions and 316 deletions

View File

@ -4,7 +4,6 @@ import { useState, useCallback, useRef, useEffect } from 'react'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { type RendererOptions } from '@/lib/markdown' import { type RendererOptions } from '@/lib/markdown'
import { useEditorSync } from './hooks/useEditorSync'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { EditorToolbar } from './components/EditorToolbar' import { EditorToolbar } from './components/EditorToolbar'
import { EditorPreview } from './components/EditorPreview' import { EditorPreview } from './components/EditorPreview'
@ -20,6 +19,11 @@ import { useEditorKeyboard } from './hooks/useEditorKeyboard'
import { useScrollSync } from './hooks/useScrollSync' import { useScrollSync } from './hooks/useScrollSync'
import { useWordStats } from './hooks/useWordStats' import { useWordStats } from './hooks/useWordStats'
import { useCopy } from './hooks/useCopy' import { useCopy } from './hooks/useCopy'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Copy } from 'lucide-react'
import { MobileEditor } from './components/MobileEditor'
import { DesktopEditor } from './components/DesktopEditor'
export default function WechatEditor() { export default function WechatEditor() {
const { toast } = useToast() const { toast } = useToast()
@ -37,10 +41,22 @@ export default function WechatEditor() {
const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id) const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
// 使用自定义 hooks // 使用自定义 hooks
const { handleScroll } = useEditorSync(editorRef)
const { handleEditorChange } = useAutoSave(value, setIsDraft) const { handleEditorChange } = useAutoSave(value, setIsDraft)
const { handleEditorScroll } = useScrollSync() const { handleEditorScroll } = useScrollSync()
// 清除编辑器内容
const handleClear = useCallback(() => {
if (window.confirm('确定要清除所有内容吗?')) {
setValue('')
handleEditorChange('')
toast({
title: "已清除",
description: "编辑器内容已清除",
duration: 2000
})
}
}, [handleEditorChange, toast])
// 手动保存 // 手动保存
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
try { try {
@ -97,12 +113,29 @@ export default function WechatEditor() {
}) })
}, [handleEditorChange]) }, [handleEditorChange])
const { handleCopy } = useCopy() const { copyToClipboard } = useCopy()
// 处理复制 const handleCopy = useCallback(async (): Promise<boolean> => {
const onCopy = useCallback(async () => { const contentElement = previewRef.current?.querySelector('.preview-content') as HTMLElement | null
return handleCopy(window.getSelection(), previewContent) if (!contentElement) return false
}, [handleCopy, previewContent])
const success = await copyToClipboard(contentElement)
if (success) {
toast({
title: "复制成功",
description: "内容已复制,可直接粘贴到公众号编辑器",
duration: 2000
})
} else {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
duration: 2000
})
}
return success
}, [copyToClipboard, toast, previewRef])
// 处理放弃草稿 // 处理放弃草稿
const handleDiscardDraft = useCallback(() => { const handleDiscardDraft = useCallback(() => {
@ -192,19 +225,6 @@ export default function WechatEditor() {
setStyleOptions({}) setStyleOptions({})
}, []) }, [])
// 清除编辑器内容
const handleClear = useCallback(() => {
if (window.confirm('确定要清除所有内容吗?')) {
setValue('')
handleEditorChange('')
toast({
title: "已清除",
description: "编辑器内容已清除",
duration: 2000
})
}
}, [handleEditorChange, toast])
// 检测是否为移动设备 // 检测是否为移动设备
const isMobile = useCallback(() => { const isMobile = useCallback(() => {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false
@ -245,141 +265,72 @@ export default function WechatEditor() {
const { wordCount, readingTime } = useWordStats(value) const { wordCount, readingTime } = useWordStats(value)
return ( return (
<div className="h-screen flex flex-col overflow-hidden"> <div className="relative flex flex-col h-screen">
<div className="hidden sm:block"> {/* 工具栏 */}
<EditorToolbar <EditorToolbar
value={value} value={value}
isDraft={isDraft} isDraft={isDraft}
showPreview={showPreview} showPreview={showPreview}
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
onSave={handleSave}
onCopy={onCopy}
onCopyPreview={onCopy}
onNewArticle={handleNewArticle}
onArticleSelect={handleArticleSelect}
onTemplateSelect={handleTemplateSelect}
onTemplateChange={() => setValue(value)}
onStyleOptionsChange={setStyleOptions}
onPreviewToggle={() => setShowPreview(!showPreview)}
styleOptions={styleOptions} styleOptions={styleOptions}
codeTheme={codeTheme}
wordCount={wordCount} wordCount={wordCount}
readingTime={readingTime} readingTime={readingTime}
codeTheme={codeTheme} onSave={handleSave}
onCopy={handleCopy}
onCopyPreview={handleCopy}
onNewArticle={handleNewArticle}
onArticleSelect={handleArticleSelect}
onTemplateSelect={(templateId: string) => setSelectedTemplate(templateId)}
onTemplateChange={() => {}}
onStyleOptionsChange={setStyleOptions}
onPreviewToggle={() => setShowPreview(!showPreview)}
onCodeThemeChange={setCodeTheme} onCodeThemeChange={setCodeTheme}
onClear={handleClear}
/> />
</div>
<div className="flex-1 flex flex-col sm:flex-row overflow-hidden"> {/* 编辑器主体 */}
{/* Mobile View */} <div className="flex-1 min-h-0 flex flex-col">
<div className="sm:hidden flex-1 flex flex-col"> {/* 移动设备编辑器 */}
<div className="flex items-center justify-between p-2 border-b bg-background"> <MobileEditor
<div className="flex-1 mr-2"> textareaRef={textareaRef}
<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
onClick={handleClear}
className="flex items-center justify-center gap-1 px-2 py-1 rounded-md text-xs text-destructive hover:bg-muted transition-colors"
title="清除内容"
>
</button>
<button
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"
>
</button>
</div>
</div>
<div className="flex-1 flex flex-col">
<div className="flex-1">
<div
ref={editorRef}
className={cn(
"h-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<textarea
ref={textareaRef}
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}
/>
</div>
</div>
<div className="flex-1">
<EditorPreview
previewRef={previewRef} previewRef={previewRef}
value={value}
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
previewSize={previewSize} previewSize={previewSize}
codeTheme={codeTheme}
previewContent={previewContent}
isConverting={isConverting}
onValueChange={setValue}
onEditorChange={handleEditorChange}
onEditorScroll={handleEditorScroll}
onPreviewSizeChange={setPreviewSize}
onCopy={handleCopy}
/>
{/* 桌面设备编辑器 */}
<DesktopEditor
editorRef={editorRef}
textareaRef={textareaRef}
previewRef={previewRef}
value={value}
selectedTemplate={selectedTemplate}
showPreview={showPreview}
previewSize={previewSize}
isConverting={isConverting} isConverting={isConverting}
previewContent={previewContent} previewContent={previewContent}
codeTheme={codeTheme} codeTheme={codeTheme}
onValueChange={setValue}
onEditorChange={handleEditorChange}
onEditorScroll={handleEditorScroll}
onPreviewSizeChange={setPreviewSize} onPreviewSizeChange={setPreviewSize}
/> onToolbarInsert={handleToolbarInsert}
</div>
</div>
</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 h-full",
showPreview
? "w-1/2 border-r"
: "w-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<MarkdownToolbar onInsert={handleToolbarInsert} />
<div className="flex-1 overflow-hidden">
<textarea
ref={textareaRef}
value={value}
onChange={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onScroll={handleEditorScroll}
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-scroll scrollbar-none"
placeholder="开始写作..."
spellCheck={false}
/> />
</div> </div>
</div>
{showPreview && (
<EditorPreview
previewRef={previewRef}
selectedTemplate={selectedTemplate}
previewSize={previewSize}
isConverting={isConverting}
previewContent={previewContent}
codeTheme={codeTheme}
onPreviewSizeChange={setPreviewSize}
/>
)}
</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="h-10 bg-background border-t flex items-center justify-end px-4 gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{wordCount} </span> <span>{wordCount} </span>
</div> </div>
@ -387,9 +338,6 @@ export default function WechatEditor() {
<span> {readingTime}</span> <span> {readingTime}</span>
</div> </div>
</div> </div>
{/* 为底部工具栏添加间距 */}
<div className="h-10" />
</div> </div>
) )
} }

View File

@ -1 +1,93 @@
'use client'
import { type RefObject } from 'react'
import { cn } from '@/lib/utils'
import { templates } from '@/config/wechat-templates'
import { EditorPreview } from './EditorPreview'
import { MarkdownToolbar } from './MarkdownToolbar'
import { type PreviewSize } from '../constants'
import { type CodeThemeId } from '@/config/code-themes'
interface DesktopEditorProps {
editorRef: RefObject<HTMLDivElement>
textareaRef: RefObject<HTMLTextAreaElement>
previewRef: RefObject<HTMLDivElement>
value: string
selectedTemplate: string
showPreview: boolean
previewSize: PreviewSize
isConverting: boolean
previewContent: string
codeTheme: CodeThemeId
onValueChange: (value: string) => void
onEditorChange: (value: string) => void
onEditorScroll: (e: React.UIEvent<HTMLTextAreaElement>) => void
onPreviewSizeChange: (size: PreviewSize) => void
onToolbarInsert: (text: string, options?: { wrap?: boolean; placeholder?: string; suffix?: string }) => void
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
}
export function DesktopEditor({
editorRef,
textareaRef,
previewRef,
value,
selectedTemplate,
showPreview,
previewSize,
isConverting,
previewContent,
codeTheme,
onValueChange,
onEditorChange,
onEditorScroll,
onPreviewSizeChange,
onToolbarInsert,
onKeyDown
}: DesktopEditorProps) {
return (
<div className="hidden md:flex flex-1 h-full">
<div
ref={editorRef}
className={cn(
"editor-container bg-background transition-all duration-300 ease-in-out flex flex-col h-full",
showPreview ? "w-1/2 border-r" : "w-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<MarkdownToolbar onInsert={onToolbarInsert} />
<div className="flex-1 overflow-hidden">
<textarea
ref={textareaRef}
value={value}
onChange={e => {
onValueChange(e.target.value)
onEditorChange(e.target.value)
}}
onKeyDown={onKeyDown}
onScroll={e => {
if (textareaRef.current) {
onEditorScroll(e)
}
}}
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed overflow-y-auto scrollbar-none"
placeholder="开始写作..."
spellCheck={false}
/>
</div>
</div>
{showPreview && (
<EditorPreview
previewRef={previewRef}
selectedTemplate={selectedTemplate}
previewSize={previewSize}
isConverting={isConverting}
previewContent={previewContent}
codeTheme={codeTheme}
onPreviewSizeChange={onPreviewSizeChange}
/>
)}
</div>
)
}

View File

@ -9,6 +9,7 @@ import { type CodeThemeId } from '@/config/code-themes'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import '@/styles/code-themes.css' import '@/styles/code-themes.css'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import { useScrollSync } from '../hooks/useScrollSync'
interface EditorPreviewProps { interface EditorPreviewProps {
previewRef: React.RefObject<HTMLDivElement> previewRef: React.RefObject<HTMLDivElement>
@ -17,6 +18,7 @@ interface EditorPreviewProps {
isConverting: boolean isConverting: boolean
previewContent: string previewContent: string
codeTheme: CodeThemeId codeTheme: CodeThemeId
showToolbar?: boolean
onPreviewSizeChange: (size: PreviewSize) => void onPreviewSizeChange: (size: PreviewSize) => void
} }
@ -27,11 +29,14 @@ export function EditorPreview({
isConverting, isConverting,
previewContent, previewContent,
codeTheme, codeTheme,
showToolbar = true,
onPreviewSizeChange onPreviewSizeChange
}: EditorPreviewProps) { }: EditorPreviewProps) {
const [zoom, setZoom] = useState(100) const [zoom, setZoom] = useState(100)
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const isScrolling = useRef<boolean>(false) const isScrolling = useRef<boolean>(false)
const contentRef = useRef<HTMLDivElement>(null)
const { handlePreviewScroll } = useScrollSync()
const { theme } = useTheme() const { theme } = useTheme()
// 初始化 Mermaid // 初始化 Mermaid
@ -161,22 +166,6 @@ export function EditorPreview({
} }
}, []) }, [])
const handleZoomIn = () => {
setZoom(prev => Math.min(prev + 10, 200))
}
const handleZoomOut = () => {
setZoom(prev => Math.max(prev - 10, 50))
}
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
previewRef.current?.requestFullscreen()
} else {
document.exitFullscreen()
}
}
return ( return (
<div <div
ref={previewRef} ref={previewRef}
@ -188,6 +177,7 @@ export function EditorPreview({
`code-theme-${codeTheme}` `code-theme-${codeTheme}`
)} )}
> >
{showToolbar && (
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0"> <div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
<div className="flex items-center gap-0.5 px-2"> <div className="flex items-center gap-0.5 px-2">
<span className="text-sm text-muted-foreground"></span> <span className="text-sm text-muted-foreground"></span>
@ -195,7 +185,7 @@ export function EditorPreview({
<div className="flex items-center gap-4 px-4 py-2"> <div className="flex items-center gap-4 px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleZoomOut} onClick={() => setZoom(zoom => Math.max(zoom - 10, 50))}
className="p-1 rounded hover:bg-muted/80 text-muted-foreground" className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
disabled={zoom <= 50} disabled={zoom <= 50}
> >
@ -203,7 +193,7 @@ export function EditorPreview({
</button> </button>
<span className="text-sm text-muted-foreground">{zoom}%</span> <span className="text-sm text-muted-foreground">{zoom}%</span>
<button <button
onClick={handleZoomIn} onClick={() => setZoom(zoom => Math.min(zoom + 10, 200))}
className="p-1 rounded hover:bg-muted/80 text-muted-foreground" className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
disabled={zoom >= 200} disabled={zoom >= 200}
> >
@ -216,12 +206,14 @@ export function EditorPreview({
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)} onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)}
className="text-sm border rounded px-2 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground" className="text-sm border rounded px-2 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
> >
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => ( {Object.entries(PREVIEW_SIZES).map(([value, { label }]) => (
<option key={key} value={key}>{label}</option> <option key={value} value={value}>
{label}
</option>
))} ))}
</select> </select>
<button <button
onClick={toggleFullscreen} onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1 rounded hover:bg-muted/80 text-muted-foreground" className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
> >
{isFullscreen ? ( {isFullscreen ? (
@ -233,28 +225,15 @@ export function EditorPreview({
</div> </div>
</div> </div>
</div> </div>
)}
<div <div
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
onScroll={(e) => { onScroll={handlePreviewScroll}
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
})
}
}}
> >
<div className="h-full py-8 px-4"> <div className="h-full py-8 px-4">
<div <div
ref={contentRef}
className={cn( className={cn(
"bg-background mx-auto rounded-lg transition-all duration-300", "bg-background mx-auto rounded-lg transition-all duration-300",
previewSize === 'full' ? '' : 'border shadow-sm' previewSize === 'full' ? '' : 'border shadow-sm'

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { Copy, Plus, Save, Smartphone, Settings, Github } from 'lucide-react' import { Copy, Plus, Save, Smartphone, Settings, Github, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { WechatStylePicker } from '../../template/WechatStylePicker' import { WechatStylePicker } from '../../template/WechatStylePicker'
import { TemplateManager } from '../../template/TemplateManager' import { TemplateManager } from '../../template/TemplateManager'
@ -38,6 +38,7 @@ interface EditorToolbarProps {
onStyleOptionsChange: (options: RendererOptions) => void onStyleOptionsChange: (options: RendererOptions) => void
onPreviewToggle: () => void onPreviewToggle: () => void
onCodeThemeChange: (theme: CodeThemeId) => void onCodeThemeChange: (theme: CodeThemeId) => void
onClear: () => void
} }
export function EditorToolbar({ export function EditorToolbar({
@ -58,7 +59,8 @@ export function EditorToolbar({
codeTheme, codeTheme,
onCodeThemeChange, onCodeThemeChange,
wordCount, wordCount,
readingTime readingTime,
onClear
}: EditorToolbarProps) { }: EditorToolbarProps) {
const { toast } = useToast() const { toast } = useToast()
@ -126,28 +128,36 @@ export function EditorToolbar({
<Logo className="w-6 h-6" /> <Logo className="w-6 h-6" />
NeuraPress NeuraPress
</Link> </Link>
<div className="hidden sm:block">
<ArticleList <ArticleList
onSelect={onArticleSelect} onSelect={onArticleSelect}
currentContent={value} currentContent={value}
onNew={onNewArticle} onNew={onNewArticle}
/> />
</div>
<WechatStylePicker <WechatStylePicker
value={selectedTemplate} value={selectedTemplate}
onSelect={onTemplateSelect} onSelect={onTemplateSelect}
/> />
<div className="hidden sm:block">
<CodeThemeSelector <CodeThemeSelector
value={codeTheme} value={codeTheme}
onChange={onCodeThemeChange} onChange={onCodeThemeChange}
/> />
</div>
<div className="hidden sm:block">
<StyleConfigDialog <StyleConfigDialog
value={styleOptions} value={styleOptions}
onChangeAction={onStyleOptionsChange} onChangeAction={onStyleOptionsChange}
/> />
</div>
<div className="hidden sm:block">
<TemplateManager onTemplateChange={onTemplateChange} /> <TemplateManager onTemplateChange={onTemplateChange} />
</div>
<button <button
onClick={onPreviewToggle} onClick={onPreviewToggle}
className={cn( className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors justify-center", "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors justify-center hidden sm:inline-flex",
showPreview showPreview
? "bg-primary text-primary-foreground hover:bg-primary/90" ? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90" : "bg-muted text-muted-foreground hover:bg-muted/90"
@ -158,16 +168,13 @@ export function EditorToolbar({
</button> </button>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{isDraft && ( <span className="text-sm text-muted-foreground hidden sm:inline">
<span className="text-sm text-muted-foreground"></span> {isDraft ? '未保存' : '已保存'}
)} </span>
{!isDraft && (
<span className="text-sm text-muted-foreground"></span>
)}
<button <button
onClick={onSave} onClick={onSave}
className={cn( className={cn(
"inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors", "inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors hidden sm:inline-flex",
isDraft isDraft
? "bg-primary text-primary-foreground hover:bg-primary/90" ? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90" : "bg-muted text-muted-foreground hover:bg-muted/90"
@ -177,6 +184,13 @@ export function EditorToolbar({
<span></span> <span></span>
</button> </button>
<button
onClick={onClear}
className="sm:hidden inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 text-sm transition-colors"
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
<button <button
onClick={handleCopyPreview} onClick={handleCopyPreview}
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm transition-colors" className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm transition-colors"
@ -184,7 +198,7 @@ export function EditorToolbar({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
<span></span> <span></span>
</button> </button>
<div className="flex items-center gap-1"> <div className="hidden sm:flex items-center gap-1">
<ThemeToggle /> <ThemeToggle />
<Button <Button
variant="ghost" variant="ghost"

View File

@ -1 +1,92 @@
'use client'
import { type RefObject } from 'react'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Copy } from 'lucide-react'
import { EditorPreview } from './EditorPreview'
import { type PreviewSize } from '../constants'
import { type CodeThemeId } from '@/config/code-themes'
interface MobileEditorProps {
textareaRef: RefObject<HTMLTextAreaElement>
previewRef: RefObject<HTMLDivElement>
value: string
selectedTemplate: string
previewSize: PreviewSize
codeTheme: CodeThemeId
previewContent: string
isConverting: boolean
onValueChange: (value: string) => void
onEditorChange: (value: string) => void
onEditorScroll: (e: React.UIEvent<HTMLTextAreaElement>) => void
onPreviewSizeChange: (size: PreviewSize) => void
onCopy: () => Promise<boolean>
}
export function MobileEditor({
textareaRef,
previewRef,
value,
selectedTemplate,
previewSize,
codeTheme,
previewContent,
isConverting,
onValueChange,
onEditorChange,
onEditorScroll,
onPreviewSizeChange,
onCopy
}: MobileEditorProps) {
return (
<div className="md:hidden h-full">
<Tabs defaultValue="edit" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="edit"></TabsTrigger>
<TabsTrigger value="preview"></TabsTrigger>
</TabsList>
<TabsContent
value="edit"
className="flex-1 hidden data-[state=active]:flex flex-col"
>
<div className="relative flex-1">
<textarea
ref={textareaRef}
value={value}
onChange={e => {
onValueChange(e.target.value)
onEditorChange(e.target.value)
}}
onScroll={e => {
if (textareaRef.current) {
onEditorScroll(e)
}
}}
className="absolute inset-0 w-full h-full resize-none border-0 bg-background p-4 focus:outline-none"
placeholder="开始写作..."
/>
</div>
</TabsContent>
<TabsContent
value="preview"
className="flex-1 hidden data-[state=active]:flex flex-col"
>
<div className="relative flex-1">
<EditorPreview
previewRef={previewRef}
selectedTemplate={selectedTemplate}
previewSize={previewSize}
isConverting={isConverting}
previewContent={previewContent}
codeTheme={codeTheme}
showToolbar={false}
onPreviewSizeChange={onPreviewSizeChange}
/>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -1,3 +1,5 @@
'use client'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { initializeMermaid } from '@/lib/markdown/mermaid-utils' import { initializeMermaid } from '@/lib/markdown/mermaid-utils'
@ -5,11 +7,13 @@ import { initializeMermaid } from '@/lib/markdown/mermaid-utils'
export const useCopy = () => { export const useCopy = () => {
const { toast } = useToast() const { toast } = useToast()
const copyToClipboard = useCallback(async (content: string) => { const copyToClipboard = useCallback(async (contentElement: HTMLElement | null) => {
if (!contentElement) return false
try { try {
// 创建临时容器并渲染内容 // 创建临时容器并渲染内容
const tempDiv = document.createElement('div') const tempDiv = document.createElement('div')
tempDiv.innerHTML = content tempDiv.innerHTML = contentElement.innerHTML
document.body.appendChild(tempDiv) document.body.appendChild(tempDiv)
// 处理 Mermaid 图表 // 处理 Mermaid 图表
@ -23,46 +27,29 @@ export const useCopy = () => {
// 获取处理后的内容 // 获取处理后的内容
const processedContent = tempDiv.innerHTML const processedContent = tempDiv.innerHTML
const plainText = tempDiv.textContent || ''
// 清理临时 div try {
document.body.removeChild(tempDiv)
// 使用 Clipboard API 写入富文本 // 使用 Clipboard API 写入富文本
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new ClipboardItem({
'text/html': new Blob([processedContent], { type: 'text/html' }), 'text/html': new Blob([processedContent], { type: 'text/html' }),
'text/plain': new Blob([content.replace(/<[^>]+>/g, '')], { type: 'text/plain' }) 'text/plain': new Blob([plainText], { type: 'text/plain' })
}) })
]) ])
} catch (error) {
// 降级处理:尝试以纯文本方式复制
await navigator.clipboard.writeText(plainText)
}
toast({ // 清理临时 div
title: "复制成功", document.body.removeChild(tempDiv)
description: "内容已复制,可直接粘贴到公众号编辑器",
duration: 2000
})
return true return true
} catch (error) { } catch (error) {
console.error('Copy error:', error) console.error('Failed to copy content:', 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 return false
} }
} }, [])
}, [toast])
const handleCopy = useCallback(async (selection: Selection | null, content: string) => { const handleCopy = useCallback(async (selection: Selection | null, content: string) => {
try { try {
@ -72,11 +59,11 @@ export const useCopy = () => {
const container = range.cloneContents() const container = range.cloneContents()
const div = document.createElement('div') const div = document.createElement('div')
div.appendChild(container) div.appendChild(container)
return await copyToClipboard(div.innerHTML) return await copyToClipboard(div)
} }
// 否则复制整个内容 // 否则复制整个内容
return await copyToClipboard(content) return await copyToClipboard(document.getElementById(content))
} catch (error) { } catch (error) {
console.error('Handle copy error:', error) console.error('Handle copy error:', error)
return false return false

View File

@ -3,38 +3,21 @@ import { useCallback, useRef } from 'react'
export const useScrollSync = () => { export const useScrollSync = () => {
const isScrolling = useRef<boolean>(false) const isScrolling = useRef<boolean>(false)
const scrollTimeout = useRef<NodeJS.Timeout>() const scrollTimeout = useRef<NodeJS.Timeout>()
const lastScrollTop = useRef<number>(0)
const handleEditorScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => { const handleEditorScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
// 如果是由于输入导致的滚动,不进行同步
if (e.currentTarget.selectionStart !== e.currentTarget.selectionEnd) {
return
}
if (isScrolling.current) return if (isScrolling.current) return
const textarea = e.currentTarget const textarea = e.currentTarget
const previewContainer = document.querySelector('.preview-container .overflow-y-auto') const previewContainer = document.querySelector('.preview-container')?.querySelector('.flex-1.overflow-y-auto')
if (!previewContainer) return if (!previewContainer) return
// 检查滚动方向和幅度
const currentScrollTop = textarea.scrollTop
const scrollDiff = currentScrollTop - lastScrollTop.current
// 如果滚动幅度太小,忽略此次滚动
if (Math.abs(scrollDiff) < 5) return
isScrolling.current = true isScrolling.current = true
lastScrollTop.current = currentScrollTop
try { try {
const scrollPercentage = currentScrollTop / (textarea.scrollHeight - textarea.clientHeight) const scrollPercentage = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight)
const targetScrollTop = scrollPercentage * (previewContainer.scrollHeight - previewContainer.clientHeight) const targetScrollTop = scrollPercentage * (previewContainer.scrollHeight - previewContainer.clientHeight)
previewContainer.scrollTo({ previewContainer.scrollTop = targetScrollTop
top: targetScrollTop,
behavior: 'instant'
})
} finally { } finally {
if (scrollTimeout.current) { if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current) clearTimeout(scrollTimeout.current)
@ -45,5 +28,29 @@ export const useScrollSync = () => {
} }
}, []) }, [])
return { handleEditorScroll } const handlePreviewScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
if (isScrolling.current) return
const previewContainer = e.currentTarget
const textarea = document.querySelector('.editor-container textarea')
if (!textarea) return
isScrolling.current = true
try {
const scrollPercentage = previewContainer.scrollTop / (previewContainer.scrollHeight - previewContainer.clientHeight)
const targetScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight)
textarea.scrollTop = targetScrollTop
} finally {
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current)
}
scrollTimeout.current = setTimeout(() => {
isScrolling.current = false
}, 50)
}
}, [])
return { handleEditorScroll, handlePreviewScroll }
} }