重构代码
This commit is contained in:
parent
af36766729
commit
13fa3ea75c
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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'
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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 }
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user