clear code

This commit is contained in:
tianyaxiang 2025-01-29 21:20:40 +08:00
parent bc9c77f79a
commit 17a5b7de2a
5 changed files with 356 additions and 127 deletions

View File

@ -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": {

View File

@ -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,14 +187,23 @@ export default function WechatEditor() {
})
} catch (err) {
console.error('Copy error:', err)
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
try {
await navigator.clipboard.writeText(previewContent.innerText)
toast({
title: "复制成功",
description: "已复制预览内容(仅文本)",
duration: 2000
})
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
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'
// 加载插件
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()
])
} catch (error) {
console.error('Failed to load editor plugins:', error)
toast({
variant: "destructive",
title: "加载失败",
description: "编辑器插件加载失败,部分功能可能无法使用",
duration: 5000,
})
}
}),
mermaid({
theme: 'default'
}),
highlight(),
createEditorPlugin()
], [createEditorPlugin])
}
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'
}}
>
<div className="flex-1 overflow-auto">
<Editor
value={value}
plugins={plugins}
onChange={handleEditorChange}
uploadImages={async (files: File[]) => {
return []
}}
/>
</div>
<Suspense fallback={<div className="flex-1 flex items-center justify-center">...</div>}>
<div className="flex-1 overflow-auto">
<Editor
value={value}
plugins={plugins}
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}
/>
)}

View File

@ -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,19 +54,48 @@ 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-2">
<select
value={previewSize}
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)}
className="text-sm border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
>
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<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}
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)}
className="text-sm border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
>
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
<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>
<div className="flex-1 overflow-y-auto">
<div className="min-h-full py-8 px-4">
<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(

View File

@ -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,53 +20,142 @@ 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">
<button
onClick={onPreviewToggle}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
showPreview
? "text-primary"
: "text-muted-foreground"
)}
>
<Smartphone className="h-5 w-5" />
{showPreview ? '编辑' : '预览'}
</button>
<button
onClick={onSave}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
isDraft
? "text-primary"
: "text-muted-foreground"
)}
>
<Save className="h-5 w-5" />
{isDraft && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />}
</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"
>
<Copy className="h-5 w-5" />
</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"
)}
>
<Copy className="h-5 w-5" />
</button>
<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(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
showPreview
? "text-primary"
: "text-muted-foreground"
)}
>
<Smartphone className="h-5 w-5" />
{showPreview ? '编辑' : '预览'}
</button>
<button
onClick={onSave}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
isDraft
? "text-primary"
: "text-muted-foreground"
)}
>
<Save className="h-5 w-5" />
{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-2 p-3 rounded-lg border hover:bg-muted/80"
>
<Copy className="h-6 w-6" />
<span className="text-xs"></span>
</button>
<button
onClick={onCopyPreview}
className="flex flex-col items-center gap-2 p-3 rounded-lg border hover:bg-muted/80"
>
<Copy className="h-6 w-6" />
<span className="text-xs"></span>
</button>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
)
}

View 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
}