/** * [INPUT]: 依赖 react(useEffect/useRef/useState)、react-dom/createPortal、framer-motion、lucide-react(X)、@/lib/motion(SPRING) * [OUTPUT]: 对外提供 ImageLightbox 组件(笔记/详情图片点击放大:暗化背景 + 滚轮缩放 + ESC/点击外部关闭) * [POS]: components/course 的图片查看器,被 MarkdownContent 消费(笔记 + 详情页图片共用,单一来源) * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { AnimatePresence, motion } from 'framer-motion' import { X } from 'lucide-react' import { SPRING } from '@/lib/motion' // 缩放边界 + 滚轮灵敏度(按 deltaY 连续缩放,exp 让放大/缩小对称) const MIN_SCALE = 1 const MAX_SCALE = 5 const WHEEL_SENSITIVITY = 0.0015 const clamp = (n, min, max) => Math.min(max, Math.max(min, n)) export function ImageLightbox({ src, alt, onClose }) { const [scale, setScale] = useState(1) const overlayRef = useRef(null) // 换图归位缩放 + ESC 关闭 + 锁背景滚动(src 即开关,null 表示关闭) useEffect(() => { if (!src) return setScale(1) const onKey = (e) => e.key === 'Escape' && onClose() window.addEventListener('keydown', onKey) const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { window.removeEventListener('keydown', onKey) document.body.style.overflow = prevOverflow } }, [src, onClose]) // 滚轮缩放:原生非 passive 监听,preventDefault 阻止页面滚动 useEffect(() => { const el = overlayRef.current if (!src || !el) return const onWheel = (e) => { e.preventDefault() setScale((s) => clamp(s * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)) } el.addEventListener('wheel', onWheel, { passive: false }) return () => el.removeEventListener('wheel', onWheel) }, [src]) return createPortal( {src ? ( {/* 外层 wrapper 管进场弹起(framer),内层 img 管滚轮缩放(CSS transform)——分属两元素互不抢 transform */} e.stopPropagation()} initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} transition={SPRING.gentle} > {/* 图片自带圆角与阴影,故不额外描边;点图片本身不关闭 */} {alt ) : null} , document.body, ) }