import { Minus, Plus, RotateCcw } from 'lucide-react'; import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent as ReactWheelEvent, } from 'react'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformIconButton } from './PlatformIconButton'; import { clampPlatformImagePreviewTransform, DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM, MAX_PLATFORM_IMAGE_PREVIEW_SCALE, MIN_PLATFORM_IMAGE_PREVIEW_SCALE, PLATFORM_IMAGE_PREVIEW_SCALE_STEP, type PlatformImagePreviewSize, type PlatformImagePreviewTransform, resolvePlatformImagePreviewContainedSize, samePlatformImagePreviewTransform, } from './platformImagePreviewModel'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; import { UnifiedModal } from './UnifiedModal'; type PlatformImagePreviewModalProps = { open: boolean; title: string; imageSrc: string | null; imageAlt: string; refreshKey?: string | number | null; closeLabel: string; zIndexClassName?: string; onClose: () => void; }; type DragState = { pointerId: number; startClientX: number; startClientY: number; startOffsetX: number; startOffsetY: number; scale: number; }; /** * 全屏图片查看器。 * 1x 保持完整图片可见;放大后拖拽会按图片边界夹住,避免拖出空背景。 */ export function PlatformImagePreviewModal({ open, title, imageSrc, imageAlt, refreshKey = null, closeLabel, zIndexClassName = 'z-[110]', onClose, }: PlatformImagePreviewModalProps) { const stageRef = useRef(null); const dragStateRef = useRef(null); const [viewportSize, setViewportSize] = useState({ width: 1, height: 1, }); const [naturalSize, setNaturalSize] = useState(null); const [transform, setTransform] = useState( DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM, ); const [isDragging, setIsDragging] = useState(false); const containedImageSize = useMemo( () => resolvePlatformImagePreviewContainedSize(viewportSize, naturalSize), [naturalSize, viewportSize], ); const clampTransform = useCallback( (nextTransform: PlatformImagePreviewTransform) => clampPlatformImagePreviewTransform( nextTransform, viewportSize, naturalSize, ), [naturalSize, viewportSize], ); const updateViewportSize = useCallback(() => { const rect = stageRef.current?.getBoundingClientRect(); setViewportSize({ width: rect?.width || window.innerWidth || 1, height: rect?.height || window.innerHeight || 1, }); }, []); const updateTransform = useCallback( ( updater: ( current: PlatformImagePreviewTransform, ) => PlatformImagePreviewTransform, ) => { setTransform((current) => { const nextTransform = clampTransform(updater(current)); return samePlatformImagePreviewTransform(current, nextTransform) ? current : nextTransform; }); }, [clampTransform], ); useEffect(() => { if (!open) { return; } setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM); setNaturalSize(null); dragStateRef.current = null; setIsDragging(false); }, [imageSrc, open, refreshKey]); useEffect(() => { if (!open) { return; } updateViewportSize(); const frameId = window.requestAnimationFrame(updateViewportSize); window.addEventListener('resize', updateViewportSize); return () => { window.cancelAnimationFrame(frameId); window.removeEventListener('resize', updateViewportSize); }; }, [open, updateViewportSize]); useEffect(() => { setTransform((current) => { const nextTransform = clampTransform(current); return samePlatformImagePreviewTransform(current, nextTransform) ? current : nextTransform; }); }, [clampTransform]); const zoomBy = useCallback( (scaleDelta: number) => { updateTransform((current) => ({ ...current, scale: current.scale + scaleDelta, })); }, [updateTransform], ); const resetTransform = useCallback(() => { setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM); }, []); const handleWheel = useCallback( (event: ReactWheelEvent) => { event.preventDefault(); zoomBy( event.deltaY < 0 ? PLATFORM_IMAGE_PREVIEW_SCALE_STEP : -PLATFORM_IMAGE_PREVIEW_SCALE_STEP, ); }, [zoomBy], ); const handlePointerDown = useCallback( (event: ReactPointerEvent) => { if (transform.scale <= MIN_PLATFORM_IMAGE_PREVIEW_SCALE) { return; } event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); dragStateRef.current = { pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startOffsetX: transform.offsetX, startOffsetY: transform.offsetY, scale: transform.scale, }; setIsDragging(true); }, [transform], ); const handlePointerMove = useCallback( (event: ReactPointerEvent) => { const dragState = dragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } event.preventDefault(); const deltaX = event.clientX - dragState.startClientX; const deltaY = event.clientY - dragState.startClientY; updateTransform(() => ({ scale: dragState.scale, offsetX: dragState.startOffsetX + deltaX, offsetY: dragState.startOffsetY + deltaY, })); }, [updateTransform], ); const finishPointerDrag = useCallback( (event: ReactPointerEvent) => { if (dragStateRef.current?.pointerId !== event.pointerId) { return; } event.currentTarget.releasePointerCapture?.(event.pointerId); dragStateRef.current = null; setIsDragging(false); }, [], ); const imageStyle = { width: `${containedImageSize.width}px`, height: `${containedImageSize.height}px`, transform: `translate3d(${transform.offsetX}px, ${transform.offsetY}px, 0) scale(${transform.scale})`, }; const canZoomOut = transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE; const canZoomIn = transform.scale < MAX_PLATFORM_IMAGE_PREVIEW_SCALE; return (
} onClick={() => zoomBy(-PLATFORM_IMAGE_PREVIEW_SCALE_STEP)} className="h-9 w-9" /> } onClick={resetTransform} className="h-9 w-9" /> } onClick={() => zoomBy(PLATFORM_IMAGE_PREVIEW_SCALE_STEP)} className="h-9 w-9" />
MIN_PLATFORM_IMAGE_PREVIEW_SCALE ? isDragging ? 'cursor-grabbing' : 'cursor-grab' : 'cursor-default', ] .filter(Boolean) .join(' ')} style={{ touchAction: 'none' }} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={finishPointerDrag} onPointerCancel={finishPointerDrag} > {imageSrc ? ( { const image = event.currentTarget; setNaturalSize({ width: image.naturalWidth || image.clientWidth || 1, height: image.naturalHeight || image.clientHeight || 1, }); }} className={`max-w-none select-none object-contain transition-transform ease-out ${ isDragging ? 'duration-0' : 'duration-150' }`} style={imageStyle} /> ) : null}
); }