返回 AiToEarn
index.tsx
1 /**
2 * MediaPreview - 媒体预览组件
3 * 用于预览图片、视频和音频,支持多媒体轮播
4 * 支持图片缩放、旋转、下载、画笔编辑,视频/音频播放
5 */
6 'use client'
7
8 import { AnimatePresence, motion } from 'framer-motion'
9 import {
10 ChevronLeft,
11 ChevronRight,
12 Download,
13 Loader2,
14 Minus,
15 Music,
16 Pencil,
17 Plus,
18 RotateCw,
19 X,
20 } from 'lucide-react'
21 import { useCallback, useEffect, useRef, useState } from 'react'
22 import { createPortal } from 'react-dom'
23 import { getOssProxyPath } from '@/utils/oss'
24 import { BrushEditor } from './BrushEditor'
25
26 const MIN_SCALE = 0.25
27 const MAX_SCALE = 5
28 const ZOOM_BUTTON_STEP = 0.25
29 const WHEEL_ZOOM_SENSITIVITY = 0.002
30 const WHEEL_ZOOM_MAX_STEP = 0.18
31 const WHEEL_TRANSITION_DELAY = 120
32
33 interface PreviewPoint {
34 x: number
35 y: number
36 }
37
38 function clampScale(value: number) {
39 return Math.min(MAX_SCALE, Math.max(MIN_SCALE, value))
40 }
41
42 export interface MediaPreviewItem {
43 type: 'image' | 'video' | 'audio'
44 src: string
45 title?: string
46 }
47
48 export interface MediaPreviewProps {
49 open: boolean
50 items: MediaPreviewItem[]
51 initialIndex?: number
52 onClose: () => void
53 /** 是否启用编辑功能 */
54 editable?: boolean
55 /** 编辑完成回调,返回编辑后的图片 URL */
56 onEdit?: (index: number, newUrl: string) => void
57 }
58
59 export function MediaPreview({
60 open,
61 items,
62 initialIndex = 0,
63 onClose,
64 editable = false,
65 onEdit,
66 }: MediaPreviewProps) {
67 const [index, setIndex] = useState(initialIndex)
68 const [scale, setScale] = useState(1)
69 const [rotate, setRotate] = useState(0)
70 const [loading, setLoading] = useState(true)
71 const [position, setPosition] = useState({ x: 0, y: 0 })
72 const [isDragging, setIsDragging] = useState(false)
73 const [isWheelZooming, setIsWheelZooming] = useState(false)
74 const [isEditorOpen, setIsEditorOpen] = useState(false)
75 const dragStart = useRef({ x: 0, y: 0 })
76 const containerRef = useRef<HTMLDivElement>(null)
77 const mediaAreaRef = useRef<HTMLDivElement>(null)
78 const imageRef = useRef<HTMLImageElement>(null)
79 const scaleRef = useRef(scale)
80 const positionRef = useRef(position)
81 const wheelZoomTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
82
83 const hasMultiple = items.length > 1
84 const current = items[index]
85 const isImage = current?.type === 'image'
86 const isAudio = current?.type === 'audio'
87
88 const updateScaleState = useCallback((nextScale: number) => {
89 scaleRef.current = nextScale
90 setScale(nextScale)
91 }, [])
92
93 const updatePositionState = useCallback((nextPosition: PreviewPoint) => {
94 positionRef.current = nextPosition
95 setPosition(nextPosition)
96 }, [])
97
98 const getImageCenter = useCallback(() => {
99 const image = imageRef.current
100 if (!image)
101 return null
102
103 const rect = image.getBoundingClientRect()
104 return {
105 x: rect.left + rect.width / 2,
106 y: rect.top + rect.height / 2,
107 }
108 }, [])
109
110 const zoomToScale = useCallback(
111 (nextScaleValue: number, anchorPoint?: PreviewPoint) => {
112 const previousScale = scaleRef.current
113 const nextScale = clampScale(nextScaleValue)
114
115 if (nextScale === previousScale)
116 return
117
118 const previousPosition = positionRef.current
119 let nextPosition = previousPosition
120
121 if (nextScale <= 1) {
122 nextPosition = { x: 0, y: 0 }
123 }
124 else {
125 const imageCenter = getImageCenter()
126 const anchor = anchorPoint ?? imageCenter
127
128 if (anchor && imageCenter) {
129 const scaleRatio = nextScale / previousScale
130 nextPosition = {
131 x: previousPosition.x + (anchor.x - imageCenter.x) * (1 - scaleRatio),
132 y: previousPosition.y + (anchor.y - imageCenter.y) * (1 - scaleRatio),
133 }
134 }
135 }
136
137 updateScaleState(nextScale)
138 updatePositionState(nextPosition)
139 },
140 [getImageCenter, updatePositionState, updateScaleState],
141 )
142
143 const markWheelZooming = useCallback(() => {
144 setIsWheelZooming(true)
145
146 if (wheelZoomTimeoutRef.current) {
147 clearTimeout(wheelZoomTimeoutRef.current)
148 }
149
150 wheelZoomTimeoutRef.current = setTimeout(() => {
151 setIsWheelZooming(false)
152 wheelZoomTimeoutRef.current = null
153 }, WHEEL_TRANSITION_DELAY)
154 }, [])
155
156 // 重置状态
157 const resetState = useCallback(() => {
158 updateScaleState(1)
159 setRotate(0)
160 updatePositionState({ x: 0, y: 0 })
161 setLoading(true)
162 setIsDragging(false)
163 }, [updatePositionState, updateScaleState])
164
165 const handlePrev = useCallback(() => {
166 if (!hasMultiple)
167 return
168 setIndex(prev => (prev - 1 + items.length) % items.length)
169 }, [hasMultiple, items.length])
170
171 const handleNext = useCallback(() => {
172 if (!hasMultiple)
173 return
174 setIndex(prev => (prev + 1) % items.length)
175 }, [hasMultiple, items.length])
176
177 const handleZoomIn = useCallback(() => zoomToScale(scaleRef.current + ZOOM_BUTTON_STEP), [zoomToScale])
178 const handleZoomOut = useCallback(() => zoomToScale(scaleRef.current - ZOOM_BUTTON_STEP), [zoomToScale])
179
180 useEffect(() => {
181 if (open) {
182 setIndex(Math.min(Math.max(initialIndex, 0), Math.max(items.length - 1, 0)))
183 resetState()
184 document.body.style.overflow = 'hidden'
185 }
186 else {
187 document.body.style.overflow = ''
188 }
189 return () => {
190 document.body.style.overflow = ''
191 }
192 }, [open, initialIndex, items.length, resetState])
193
194 useEffect(() => {
195 resetState()
196 }, [index, resetState])
197
198 useEffect(() => {
199 return () => {
200 if (wheelZoomTimeoutRef.current) {
201 clearTimeout(wheelZoomTimeoutRef.current)
202 }
203 }
204 }, [])
205
206 useEffect(() => {
207 if (!open || !isImage)
208 return
209
210 const mediaArea = mediaAreaRef.current
211 if (!mediaArea)
212 return
213
214 const handleWheel = (event: WheelEvent) => {
215 event.preventDefault()
216 event.stopPropagation()
217 markWheelZooming()
218
219 const scaleStep = Math.min(
220 WHEEL_ZOOM_MAX_STEP,
221 Math.max(-WHEEL_ZOOM_MAX_STEP, -event.deltaY * WHEEL_ZOOM_SENSITIVITY),
222 )
223 const zoomFactor = 1 + scaleStep
224
225 zoomToScale(scaleRef.current * zoomFactor, {
226 x: event.clientX,
227 y: event.clientY,
228 })
229 }
230
231 mediaArea.addEventListener('wheel', handleWheel, { passive: false })
232 return () => mediaArea.removeEventListener('wheel', handleWheel)
233 }, [isImage, markWheelZooming, open, zoomToScale])
234
235 useEffect(() => {
236 if (!open)
237 return
238
239 const handleKeyDown = (e: KeyboardEvent) => {
240 switch (e.key) {
241 case 'Escape':
242 e.preventDefault()
243 e.stopPropagation()
244 onClose()
245 break
246 case 'ArrowLeft':
247 e.preventDefault()
248 e.stopPropagation()
249 handlePrev()
250 break
251 case 'ArrowRight':
252 e.preventDefault()
253 e.stopPropagation()
254 handleNext()
255 break
256 case '+':
257 case '=':
258 e.preventDefault()
259 e.stopPropagation()
260 handleZoomIn()
261 break
262 case '-':
263 e.preventDefault()
264 e.stopPropagation()
265 handleZoomOut()
266 break
267 }
268 }
269
270 window.addEventListener('keydown', handleKeyDown, true)
271 return () => window.removeEventListener('keydown', handleKeyDown, true)
272 }, [open, onClose, handlePrev, handleNext, handleZoomIn, handleZoomOut])
273
274 const handleRotate = () => setRotate(r => r + 90)
275
276 /** 打开编辑器 */
277 const handleOpenEditor = useCallback(() => {
278 setIsEditorOpen(true)
279 }, [])
280
281 /** 编辑保存回调 */
282 const handleEditorSave = useCallback(
283 (newUrl: string) => {
284 onEdit?.(index, newUrl)
285 setIsEditorOpen(false)
286 },
287 [index, onEdit],
288 )
289
290 const handleDownload = () => {
291 if (!current?.src)
292 return
293 const url = getOssProxyPath(current.src)
294 const link = document.createElement('a')
295 link.href = url
296 link.download = url.split('/').pop() || 'media'
297 link.target = '_blank'
298 document.body.appendChild(link)
299 link.click()
300 document.body.removeChild(link)
301 }
302
303 const handleMouseDown = (e: React.MouseEvent) => {
304 if (!isImage || scaleRef.current <= 1)
305 return
306 e.preventDefault()
307 setIsDragging(true)
308 dragStart.current = {
309 x: e.clientX - positionRef.current.x,
310 y: e.clientY - positionRef.current.y,
311 }
312 }
313
314 const handleMouseMove = useCallback(
315 (e: React.MouseEvent) => {
316 if (!isDragging)
317 return
318 updatePositionState({
319 x: e.clientX - dragStart.current.x,
320 y: e.clientY - dragStart.current.y,
321 })
322 },
323 [isDragging, updatePositionState],
324 )
325
326 const handleMouseUp = () => setIsDragging(false)
327
328 const handleDoubleClick = (e: React.MouseEvent<HTMLImageElement>) => {
329 if (!isImage)
330 return
331 if (scaleRef.current <= 1) {
332 zoomToScale(2, { x: e.clientX, y: e.clientY })
333 }
334 else {
335 zoomToScale(1)
336 }
337 }
338
339 const handleBackdropClick = (e: React.MouseEvent) => {
340 e.stopPropagation()
341 if (e.target === e.currentTarget) {
342 onClose()
343 }
344 }
345
346 // SSR 检查
347 if (typeof window === 'undefined')
348 return null
349
350 const portalContent = createPortal(
351 <AnimatePresence>
352 {open && current && (
353 <motion.div
354 initial={{ opacity: 0 }}
355 animate={{ opacity: 1 }}
356 exit={{ opacity: 0 }}
357 transition={{ duration: 0.2 }}
358 className="fixed inset-0 z-[9999] flex items-center justify-center"
359 style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
360 onClick={handleBackdropClick}
361 onMouseMove={handleMouseMove}
362 onMouseUp={handleMouseUp}
363 onMouseLeave={handleMouseUp}
364 ref={containerRef}
365 >
366 {/* 顶部工具栏 */}
367 <div
368 className="absolute top-0 left-0 right-0 h-14 flex items-center justify-center z-10"
369 style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
370 >
371 <div className="flex items-center gap-1">
372 <button
373 type="button"
374 onClick={handleDownload}
375 className="flex items-center justify-center w-10 h-10 text-white/80 hover:text-white transition-colors cursor-pointer"
376 title="下载"
377 >
378 <Download size={20} />
379 </button>
380
381 {isImage && (
382 <>
383 <div className="w-px h-5 bg-white/20 mx-2" />
384
385 <button
386 type="button"
387 onClick={handleZoomIn}
388 className="flex items-center justify-center w-10 h-10 text-white/80 hover:text-white transition-colors cursor-pointer"
389 title="放大"
390 >
391 <Plus size={20} />
392 </button>
393
394 <span className="text-white/80 text-sm min-w-[60px] text-center select-none">
395 {Math.round(scale * 100)}
396 %
397 </span>
398
399 <button
400 type="button"
401 onClick={handleZoomOut}
402 className="flex items-center justify-center w-10 h-10 text-white/80 hover:text-white transition-colors cursor-pointer"
403 title="缩小"
404 >
405 <Minus size={20} />
406 </button>
407
408 <div className="w-px h-5 bg-white/20 mx-2" />
409
410 <button
411 type="button"
412 onClick={handleRotate}
413 className="flex items-center justify-center w-10 h-10 text-white/80 hover:text-white transition-colors cursor-pointer"
414 title="旋转"
415 >
416 <RotateCw size={20} />
417 </button>
418
419 {/* 编辑按钮 */}
420 {editable && onEdit && (
421 <>
422 <div className="w-px h-5 bg-white/20 mx-2" />
423 <button
424 type="button"
425 onClick={handleOpenEditor}
426 className="flex items-center justify-center w-10 h-10 text-white/80 hover:text-white transition-colors cursor-pointer"
427 title="编辑"
428 >
429 <Pencil size={20} />
430 </button>
431 </>
432 )}
433 </>
434 )}
435 </div>
436
437 <button
438 type="button"
439 onClick={(e) => {
440 e.stopPropagation()
441 onClose()
442 }}
443 className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center justify-center w-10 h-10 text-white/80 hover:text-white transition-colors cursor-pointer"
444 title="关闭"
445 >
446 <X size={24} />
447 </button>
448 </div>
449
450 {/* 媒体内容区域 */}
451 <div
452 className="relative flex items-center justify-center w-full h-full pt-14 pb-12"
453 onClick={handleBackdropClick}
454 ref={mediaAreaRef}
455 >
456 <AnimatePresence mode="wait">
457 <motion.div
458 key={`${current.src}-${index}`}
459 initial={{ opacity: 0, scale: 0.95 }}
460 animate={{ opacity: 1, scale: 1 }}
461 exit={{ opacity: 0, scale: 0.95 }}
462 transition={{ duration: 0.15 }}
463 className="relative flex items-center justify-center"
464 style={{ maxWidth: '90vw', maxHeight: 'calc(100vh - 120px)' }}
465 >
466 {loading && (
467 <div className="absolute inset-0 flex items-center justify-center z-10">
468 <Loader2 className="w-10 h-10 text-white/60 animate-spin" />
469 </div>
470 )}
471
472 {isImage ? (
473 <img
474 ref={imageRef}
475 src={current.src}
476 alt={current.title || 'preview'}
477 onLoad={() => setLoading(false)}
478 onError={() => setLoading(false)}
479 onMouseDown={handleMouseDown}
480 onDoubleClick={handleDoubleClick}
481 draggable={false}
482 className="select-none"
483 style={{
484 maxWidth: '90vw',
485 maxHeight: 'calc(100vh - 120px)',
486 objectFit: 'contain',
487 transform: `translate(${position.x}px, ${position.y}px) rotate(${rotate}deg) scale(${scale})`,
488 transformOrigin: 'center center',
489 transition: isDragging || isWheelZooming ? 'none' : 'transform 0.15s ease-out',
490 cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
491 opacity: loading ? 0 : 1,
492 }}
493 />
494 ) : isAudio ? (
495 <div className="flex w-[min(90vw,420px)] max-w-[90vw] flex-col items-center gap-4 rounded-xl border border-border bg-card p-6 text-card-foreground shadow-sm">
496 <div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
497 <Music size={28} />
498 </div>
499 {current.title && (
500 <div className="max-w-full truncate text-sm font-medium">
501 {current.title}
502 </div>
503 )}
504 <audio
505 src={current.src}
506 controls
507 autoPlay
508 preload="metadata"
509 onLoadedMetadata={() => setLoading(false)}
510 onCanPlay={() => setLoading(false)}
511 onError={() => setLoading(false)}
512 className="w-full"
513 />
514 </div>
515 ) : (
516 <video
517 src={current.src}
518 controls
519 autoPlay
520 onLoadedData={() => setLoading(false)}
521 onError={() => setLoading(false)}
522 className="select-none"
523 style={{
524 maxWidth: '90vw',
525 maxHeight: 'calc(100vh - 120px)',
526 objectFit: 'contain',
527 opacity: loading ? 0 : 1,
528 }}
529 />
530 )}
531 </motion.div>
532 </AnimatePresence>
533 </div>
534
535 {/* 左右切换按钮 */}
536 {hasMultiple && (
537 <>
538 <button
539 type="button"
540 onClick={handlePrev}
541 className="absolute left-4 top-1/2 -translate-y-1/2 flex items-center justify-center w-12 h-12 rounded-full bg-black/50 text-white/80 hover:text-white hover:bg-black/70 transition-all cursor-pointer"
542 title="上一个"
543 >
544 <ChevronLeft size={28} />
545 </button>
546 <button
547 type="button"
548 onClick={handleNext}
549 className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center justify-center w-12 h-12 rounded-full bg-black/50 text-white/80 hover:text-white hover:bg-black/70 transition-all cursor-pointer"
550 title="下一个"
551 >
552 <ChevronRight size={28} />
553 </button>
554 </>
555 )}
556
557 {/* 底部页码指示器 */}
558 {hasMultiple && (
559 <div
560 className="absolute bottom-0 left-0 right-0 h-12 flex items-center justify-center z-10"
561 style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
562 >
563 <div className="flex items-center gap-2">
564 {items.map((_, i) => (
565 <button
566 key={i}
567 type="button"
568 onClick={() => setIndex(i)}
569 className={`w-2 h-2 rounded-full transition-all cursor-pointer ${
570 i === index ? 'bg-white w-4' : 'bg-white/40 hover:bg-white/60'
571 }`}
572 />
573 ))}
574 </div>
575 <span className="absolute right-4 text-white/60 text-sm">
576 {index + 1}
577 {' '}
578 /
579 {items.length}
580 </span>
581 </div>
582 )}
583 </motion.div>
584 )}
585 </AnimatePresence>,
586 document.body,
587 )
588
589 return (
590 <>
591 {portalContent}
592
593 {/* 画笔编辑器 */}
594 {editable && isImage && current && (
595 <BrushEditor
596 open={isEditorOpen}
597 imageUrl={current.src}
598 onClose={() => setIsEditorOpen(false)}
599 onSave={handleEditorSave}
600 />
601 )}
602 </>
603 )
604 }
605
606 export default MediaPreview
607
607 lines Plain Text