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,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} | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
| @ -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( | ||||
|  | ||||
| @ -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> | ||||
|   ) | ||||
| }  | ||||
							
								
								
									
										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
	 tianyaxiang
						tianyaxiang