clear code
This commit is contained in:
parent
bc9c77f79a
commit
17a5b7de2a
@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "NODE_NO_WARNINGS=1 next dev",
|
||||
"build": "NODE_NO_WARNINGS=1 next build",
|
||||
"start": "NODE_NO_WARNINGS=1 next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -7,7 +7,7 @@ import breaks from '@bytemd/plugin-breaks'
|
||||
import frontmatter from '@bytemd/plugin-frontmatter'
|
||||
import math from '@bytemd/plugin-math'
|
||||
import mermaid from '@bytemd/plugin-mermaid'
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, Suspense } from 'react'
|
||||
import { templates } from '@/config/wechat-templates'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@ -36,11 +36,41 @@ export default function WechatEditor() {
|
||||
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [isDraft, setIsDraft] = useState(false)
|
||||
const [previewContent, setPreviewContent] = useState('')
|
||||
const [plugins, setPlugins] = useState<BytemdPlugin[]>([])
|
||||
|
||||
// 使用自定义 hooks
|
||||
const { handleScroll } = useEditorSync(editorRef)
|
||||
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
||||
|
||||
// 获取预览内容
|
||||
const getPreviewContent = useCallback(() => {
|
||||
if (!value) return ''
|
||||
|
||||
const template = templates.find(t => t.id === selectedTemplate)
|
||||
const mergedOptions = {
|
||||
...styleOptions,
|
||||
...(template?.options || {})
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -91,33 +121,6 @@ export default function WechatEditor() {
|
||||
}
|
||||
}, [handleSave])
|
||||
|
||||
const getPreviewContent = useCallback(() => {
|
||||
if (!value) return ''
|
||||
|
||||
const template = templates.find(t => t.id === selectedTemplate)
|
||||
const mergedOptions = {
|
||||
...styleOptions,
|
||||
...(template?.options || {})
|
||||
}
|
||||
|
||||
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 copyContent = useCallback(() => {
|
||||
const content = getPreviewContent()
|
||||
navigator.clipboard.writeText(content)
|
||||
@ -135,7 +138,7 @@ export default function WechatEditor() {
|
||||
}, [getPreviewContent, toast])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
|
||||
const previewContent = previewRef.current?.querySelector('.preview-content') as HTMLElement | null
|
||||
if (!previewContent) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@ -155,6 +158,16 @@ export default function WechatEditor() {
|
||||
tempDiv.className = template.styles
|
||||
}
|
||||
|
||||
if (!tempDiv.innerHTML.trim()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "复制失败",
|
||||
description: "预览内容为空",
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
|
||||
const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' })
|
||||
|
||||
@ -174,6 +187,14 @@ export default function WechatEditor() {
|
||||
})
|
||||
} 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: "复制失败",
|
||||
@ -181,7 +202,8 @@ export default function WechatEditor() {
|
||||
action: <ToastAction altText="重试">重试</ToastAction>,
|
||||
})
|
||||
}
|
||||
}, [selectedTemplate, toast])
|
||||
}
|
||||
}, [selectedTemplate, toast, previewRef])
|
||||
|
||||
// 创建编辑器插件
|
||||
const createEditorPlugin = useCallback((): BytemdPlugin => {
|
||||
@ -202,26 +224,63 @@ export default function WechatEditor() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 使用创建的插件
|
||||
const plugins = useMemo(() => [
|
||||
gfm(),
|
||||
breaks(),
|
||||
frontmatter(),
|
||||
math({
|
||||
katexOptions: {
|
||||
throwOnError: false,
|
||||
output: 'html'
|
||||
}
|
||||
}),
|
||||
mermaid({
|
||||
theme: 'default'
|
||||
}),
|
||||
highlight(),
|
||||
// 加载插件
|
||||
useEffect(() => {
|
||||
const loadPlugins = async () => {
|
||||
try {
|
||||
const [gfmPlugin, breaksPlugin, frontmatterPlugin, mathPlugin, mermaidPlugin, highlightPlugin] = await Promise.all([
|
||||
import('@bytemd/plugin-gfm').then(m => m.default()),
|
||||
import('@bytemd/plugin-breaks').then(m => m.default()),
|
||||
import('@bytemd/plugin-frontmatter').then(m => m.default()),
|
||||
import('@bytemd/plugin-math').then(m => m.default({
|
||||
katexOptions: { throwOnError: false, output: 'html' }
|
||||
})),
|
||||
import('@bytemd/plugin-mermaid').then(m => m.default({ theme: 'default' })),
|
||||
import('@bytemd/plugin-highlight').then(m => m.default())
|
||||
])
|
||||
|
||||
setPlugins([
|
||||
gfmPlugin,
|
||||
breaksPlugin,
|
||||
frontmatterPlugin,
|
||||
mathPlugin,
|
||||
mermaidPlugin,
|
||||
highlightPlugin,
|
||||
createEditorPlugin()
|
||||
], [createEditorPlugin])
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to load editor plugins:', error)
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "加载失败",
|
||||
description: "编辑器插件加载失败,部分功能可能无法使用",
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
loadPlugins()
|
||||
}, [createEditorPlugin, toast])
|
||||
|
||||
// 延迟更新预览内容
|
||||
useEffect(() => {
|
||||
if (!showPreview) return
|
||||
|
||||
setIsConverting(true)
|
||||
const updatePreview = async () => {
|
||||
try {
|
||||
const content = getPreviewContent()
|
||||
setPreviewContent(content)
|
||||
} finally {
|
||||
setIsConverting(false)
|
||||
}
|
||||
}
|
||||
updatePreview()
|
||||
}, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent])
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = useCallback(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.innerWidth < 640
|
||||
}, [])
|
||||
|
||||
@ -307,16 +366,21 @@ export default function WechatEditor() {
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center">加载编辑器...</div>}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Editor
|
||||
value={value}
|
||||
plugins={plugins}
|
||||
onChange={handleEditorChange}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
handleEditorChange(v)
|
||||
}}
|
||||
uploadImages={async (files: File[]) => {
|
||||
return []
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
@ -325,7 +389,7 @@ export default function WechatEditor() {
|
||||
selectedTemplate={selectedTemplate}
|
||||
previewSize={previewSize}
|
||||
isConverting={isConverting}
|
||||
previewContent={getPreviewContent()}
|
||||
previewContent={previewContent}
|
||||
onPreviewSizeChange={setPreviewSize}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { templates } from '@/config/wechat-templates'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface EditorPreviewProps {
|
||||
previewRef: React.RefObject<HTMLDivElement>
|
||||
@ -20,6 +21,27 @@ export function EditorPreview({
|
||||
previewContent,
|
||||
onPreviewSizeChange
|
||||
}: EditorPreviewProps) {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
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()
|
||||
setIsFullscreen(true)
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
@ -32,6 +54,24 @@ export function EditorPreview({
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-4 py-2 flex items-center justify-between z-10 sticky top-0">
|
||||
<div className="text-sm text-muted-foreground">预览效果</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
|
||||
disabled={zoom <= 50}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">{zoom}%</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
|
||||
disabled={zoom >= 200}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={previewSize}
|
||||
@ -42,6 +82,17 @@ export function EditorPreview({
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -54,12 +105,16 @@ export function EditorPreview({
|
||||
)}
|
||||
style={{
|
||||
width: PREVIEW_SIZES[previewSize].width,
|
||||
maxWidth: '100%'
|
||||
maxWidth: '100%',
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'top center',
|
||||
transition: 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{isConverting ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">正在生成预览...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Copy, Save, Smartphone } from 'lucide-react'
|
||||
import { Copy, Save, Smartphone, Settings, Image, Link, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
||||
|
||||
interface MobileToolbarProps {
|
||||
showPreview: boolean
|
||||
@ -8,6 +10,8 @@ interface MobileToolbarProps {
|
||||
onSave: () => void
|
||||
onCopy: () => void
|
||||
onCopyPreview: () => void
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
onLinkInsert?: (url: string) => void
|
||||
}
|
||||
|
||||
export function MobileToolbar({
|
||||
@ -16,10 +20,53 @@ export function MobileToolbar({
|
||||
onPreviewToggle,
|
||||
onSave,
|
||||
onCopy,
|
||||
onCopyPreview
|
||||
onCopyPreview,
|
||||
onImageUpload,
|
||||
onLinkInsert
|
||||
}: MobileToolbarProps) {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setZoom(prev => Math.min(prev + 10, 200))
|
||||
}
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setZoom(prev => Math.max(prev - 10, 50))
|
||||
}
|
||||
|
||||
const handleImageUpload = async () => {
|
||||
if (!onImageUpload) return
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
try {
|
||||
const url = await onImageUpload(file)
|
||||
// 处理上传成功后的图片URL
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const handleLinkInsert = () => {
|
||||
if (!onLinkInsert) return
|
||||
|
||||
const url = window.prompt('请输入链接地址')
|
||||
if (url) {
|
||||
onLinkInsert(url)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:hidden fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t p-2 flex justify-around">
|
||||
<div className="sm:hidden fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPreviewToggle}
|
||||
className={cn(
|
||||
@ -45,24 +92,70 @@ export function MobileToolbar({
|
||||
保存
|
||||
{isDraft && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showPreview && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded-md text-muted-foreground"
|
||||
disabled={zoom <= 50}
|
||||
>
|
||||
<ZoomOut className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{zoom}%</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded-md text-muted-foreground"
|
||||
disabled={zoom >= 200}
|
||||
>
|
||||
<ZoomIn className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<button className="p-1 rounded-md text-muted-foreground">
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" className="h-[40vh]">
|
||||
<div className="grid grid-cols-4 gap-4 p-4">
|
||||
<button
|
||||
onClick={handleImageUpload}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border hover:bg-muted/80"
|
||||
>
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs">插入图片</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLinkInsert}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border hover:bg-muted/80"
|
||||
>
|
||||
<Link className="h-6 w-6" />
|
||||
<span className="text-xs">插入链接</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs text-muted-foreground transition-colors relative"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border hover:bg-muted/80"
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
源码
|
||||
<Copy className="h-6 w-6" />
|
||||
<span className="text-xs">复制源码</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCopyPreview}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
|
||||
"hover:bg-primary/10 active:bg-primary/20",
|
||||
showPreview ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border hover:bg-muted/80"
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
复制预览
|
||||
<Copy className="h-6 w-6" />
|
||||
<span className="text-xs">复制预览</span>
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
17
src/components/editor/hooks/useDebounce.ts
Normal file
17
src/components/editor/hooks/useDebounce.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
Loading…
Reference in New Issue
Block a user