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", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "NODE_NO_WARNINGS=1 next dev",
"build": "next build", "build": "NODE_NO_WARNINGS=1 next build",
"start": "next start", "start": "NODE_NO_WARNINGS=1 next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {

View File

@ -7,7 +7,7 @@ import breaks from '@bytemd/plugin-breaks'
import frontmatter from '@bytemd/plugin-frontmatter' import frontmatter from '@bytemd/plugin-frontmatter'
import math from '@bytemd/plugin-math' import math from '@bytemd/plugin-math'
import mermaid from '@bytemd/plugin-mermaid' 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 { templates } from '@/config/wechat-templates'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
@ -36,11 +36,41 @@ export default function WechatEditor() {
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium') const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
const [isConverting, setIsConverting] = useState(false) const [isConverting, setIsConverting] = useState(false)
const [isDraft, setIsDraft] = useState(false) const [isDraft, setIsDraft] = useState(false)
const [previewContent, setPreviewContent] = useState('')
const [plugins, setPlugins] = useState<BytemdPlugin[]>([])
// 使用自定义 hooks // 使用自定义 hooks
const { handleScroll } = useEditorSync(editorRef) const { handleScroll } = useEditorSync(editorRef)
const { handleEditorChange } = useAutoSave(value, setIsDraft) 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(() => { const handleSave = useCallback(() => {
try { try {
@ -91,33 +121,6 @@ export default function WechatEditor() {
} }
}, [handleSave]) }, [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 copyContent = useCallback(() => {
const content = getPreviewContent() const content = getPreviewContent()
navigator.clipboard.writeText(content) navigator.clipboard.writeText(content)
@ -135,7 +138,7 @@ export default function WechatEditor() {
}, [getPreviewContent, toast]) }, [getPreviewContent, toast])
const handleCopy = useCallback(async () => { 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) { if (!previewContent) {
toast({ toast({
variant: "destructive", variant: "destructive",
@ -155,6 +158,16 @@ export default function WechatEditor() {
tempDiv.className = template.styles 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 htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' }) const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' })
@ -174,14 +187,23 @@ export default function WechatEditor() {
}) })
} catch (err) { } catch (err) {
console.error('Copy error:', err) console.error('Copy error:', err)
toast({ try {
variant: "destructive", await navigator.clipboard.writeText(previewContent.innerText)
title: "复制失败", toast({
description: "无法访问剪贴板,请检查浏览器权限", title: "复制成功",
action: <ToastAction altText="重试"></ToastAction>, description: "已复制预览内容(仅文本)",
}) duration: 2000
})
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
}
} }
}, [selectedTemplate, toast]) }, [selectedTemplate, toast, previewRef])
// 创建编辑器插件 // 创建编辑器插件
const createEditorPlugin = useCallback((): BytemdPlugin => { const createEditorPlugin = useCallback((): BytemdPlugin => {
@ -202,26 +224,63 @@ export default function WechatEditor() {
} }
}, []) }, [])
// 使用创建的插件 // 加载插件
const plugins = useMemo(() => [ useEffect(() => {
gfm(), const loadPlugins = async () => {
breaks(), try {
frontmatter(), const [gfmPlugin, breaksPlugin, frontmatterPlugin, mathPlugin, mermaidPlugin, highlightPlugin] = await Promise.all([
math({ import('@bytemd/plugin-gfm').then(m => m.default()),
katexOptions: { import('@bytemd/plugin-breaks').then(m => m.default()),
throwOnError: false, import('@bytemd/plugin-frontmatter').then(m => m.default()),
output: 'html' 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' loadPlugins()
}), }, [createEditorPlugin, toast])
highlight(),
createEditorPlugin() // 延迟更新预览内容
], [createEditorPlugin]) 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(() => { const isMobile = useCallback(() => {
if (typeof window === 'undefined') return false
return window.innerWidth < 640 return window.innerWidth < 640
}, []) }, [])
@ -307,16 +366,21 @@ export default function WechatEditor() {
flexDirection: 'column' flexDirection: 'column'
}} }}
> >
<div className="flex-1 overflow-auto"> <Suspense fallback={<div className="flex-1 flex items-center justify-center">...</div>}>
<Editor <div className="flex-1 overflow-auto">
value={value} <Editor
plugins={plugins} value={value}
onChange={handleEditorChange} plugins={plugins}
uploadImages={async (files: File[]) => { onChange={(v) => {
return [] setValue(v)
}} handleEditorChange(v)
/> }}
</div> uploadImages={async (files: File[]) => {
return []
}}
/>
</div>
</Suspense>
</div> </div>
{showPreview && ( {showPreview && (
@ -325,7 +389,7 @@ export default function WechatEditor() {
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
previewSize={previewSize} previewSize={previewSize}
isConverting={isConverting} isConverting={isConverting}
previewContent={getPreviewContent()} previewContent={previewContent}
onPreviewSizeChange={setPreviewSize} onPreviewSizeChange={setPreviewSize}
/> />
)} )}

View File

@ -1,7 +1,8 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { PREVIEW_SIZES, type PreviewSize } from '../constants' 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 { templates } from '@/config/wechat-templates'
import { useState } from 'react'
interface EditorPreviewProps { interface EditorPreviewProps {
previewRef: React.RefObject<HTMLDivElement> previewRef: React.RefObject<HTMLDivElement>
@ -20,6 +21,27 @@ export function EditorPreview({
previewContent, previewContent,
onPreviewSizeChange onPreviewSizeChange
}: EditorPreviewProps) { }: 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 ( return (
<div <div
ref={previewRef} ref={previewRef}
@ -32,16 +54,45 @@ 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="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="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<select <div className="flex items-center gap-2">
value={previewSize} <button
onChange={(e) => onPreviewSizeChange(e.target.value as PreviewSize)} onClick={handleZoomOut}
className="text-sm border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground" className="p-1 rounded hover:bg-muted/80 text-muted-foreground"
> disabled={zoom <= 50}
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => ( >
<option key={key} value={key}>{label}</option> <ZoomOut className="h-4 w-4" />
))} </button>
</select> <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> </div>
@ -54,12 +105,16 @@ export function EditorPreview({
)} )}
style={{ style={{
width: PREVIEW_SIZES[previewSize].width, width: PREVIEW_SIZES[previewSize].width,
maxWidth: '100%' maxWidth: '100%',
transform: `scale(${zoom / 100})`,
transformOrigin: 'top center',
transition: 'transform 0.2s ease-in-out'
}} }}
> >
{isConverting ? ( {isConverting ? (
<div className="flex items-center justify-center p-8"> <div className="flex flex-col items-center justify-center gap-2 p-8">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">...</span>
</div> </div>
) : ( ) : (
<div className={cn( <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 { cn } from '@/lib/utils'
import { useState } from 'react'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
interface MobileToolbarProps { interface MobileToolbarProps {
showPreview: boolean showPreview: boolean
@ -8,6 +10,8 @@ interface MobileToolbarProps {
onSave: () => void onSave: () => void
onCopy: () => void onCopy: () => void
onCopyPreview: () => void onCopyPreview: () => void
onImageUpload?: (file: File) => Promise<string>
onLinkInsert?: (url: string) => void
} }
export function MobileToolbar({ export function MobileToolbar({
@ -16,53 +20,142 @@ export function MobileToolbar({
onPreviewToggle, onPreviewToggle,
onSave, onSave,
onCopy, onCopy,
onCopyPreview onCopyPreview,
onImageUpload,
onLinkInsert
}: MobileToolbarProps) { }: 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 ( 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">
<button <div className="flex items-center justify-between p-2">
onClick={onPreviewToggle} <div className="flex items-center gap-2">
className={cn( <button
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative", onClick={onPreviewToggle}
showPreview className={cn(
? "text-primary" "flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
: "text-muted-foreground" showPreview
)} ? "text-primary"
> : "text-muted-foreground"
<Smartphone className="h-5 w-5" /> )}
{showPreview ? '编辑' : '预览'} >
</button> <Smartphone className="h-5 w-5" />
<button {showPreview ? '编辑' : '预览'}
onClick={onSave} </button>
className={cn( <button
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative", onClick={onSave}
isDraft className={cn(
? "text-primary" "flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
: "text-muted-foreground" 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" />} <Save className="h-5 w-5" />
</button>
<button {isDraft && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />}
onClick={onCopy} </button>
className="flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs text-muted-foreground transition-colors relative" </div>
>
<Copy className="h-5 w-5" /> <div className="flex items-center gap-2">
{showPreview && (
</button> <>
<button <button
onClick={onCopyPreview} onClick={handleZoomOut}
className={cn( className="p-1 rounded-md text-muted-foreground"
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative", disabled={zoom <= 50}
"hover:bg-primary/10 active:bg-primary/20", >
showPreview ? "text-primary" : "text-muted-foreground" <ZoomOut className="h-5 w-5" />
)} </button>
> <span className="text-xs text-muted-foreground">{zoom}%</span>
<Copy className="h-5 w-5" /> <button
onClick={handleZoomIn}
</button> 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> </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
}