clear code
This commit is contained in:
parent
bc9c77f79a
commit
17a5b7de2a
@ -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": {
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -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(
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
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