"use client"; import { useEffect, useCallback, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, RotateCw, RotateCcw, FlipHorizontal, Contrast, Undo2, } from "lucide-react"; export type LightboxItem = { src: string; alt?: string; }; type ImageLightboxProps = { items: LightboxItem[]; index: number; onClose: () => void; onIndexChange: (index: number) => void; }; type View = { scale: number; rotation: number; invert: boolean; flipX: boolean; tx: number; ty: number; }; const IDENTITY: View = { scale: 1, rotation: 0, invert: false, flipX: false, tx: 0, ty: 0, }; const MIN_SCALE = 0.25; const MAX_SCALE = 8; const ZOOM_STEP = 1.25; function clampScale(n: number) { return Math.min(MAX_SCALE, Math.max(MIN_SCALE, n)); } function norm360(deg: number) { return ((deg % 360) + 360) % 360; } function isIdentity(v: View) { return ( v.scale === 1 && norm360(v.rotation) === 0 && !v.invert && !v.flipX && v.tx === 0 && v.ty === 0 ); } function ToolBtn({ label, pressed, disabled, onClick, children, className = "", }: { label: string; pressed?: boolean; disabled?: boolean; onClick: () => void; children: ReactNode; className?: string; }) { return ( ); } /** 全屏图箱:Esc 关闭,多图时左右切换;支持缩放 / 旋转 / 翻转 / 反色 */ export default function ImageLightbox({ items, index, onClose, onIndexChange, }: ImageLightboxProps) { const total = items.length; const current = items[index]; const hasNav = total > 1; const [view, setView] = useState(IDENTITY); const [panning, setPanning] = useState(false); const [snap, setSnap] = useState(false); const viewRef = useRef(view); viewRef.current = view; const rootRef = useRef(null); const stageRef = useRef(null); const dragRef = useRef<{ x: number; y: number; tx: number; ty: number; } | null>(null); const goPrev = useCallback(() => { if (!hasNav) return; onIndexChange((index - 1 + total) % total); }, [hasNav, index, total, onIndexChange]); const goNext = useCallback(() => { if (!hasNav) return; onIndexChange((index + 1) % total); }, [hasNav, index, total, onIndexChange]); const zoomBy = useCallback((factor: number, origin?: { x: number; y: number }) => { setView((v) => { const next = clampScale(v.scale * factor); if (next === v.scale) return v; if (!origin) { const tx = next <= 1 ? 0 : v.tx; const ty = next <= 1 ? 0 : v.ty; return { ...v, scale: next, tx, ty }; } const tx = origin.x - ((origin.x - v.tx) / v.scale) * next; const ty = origin.y - ((origin.y - v.ty) / v.scale) * next; return { ...v, scale: next, tx: next <= 1 ? 0 : tx, ty: next <= 1 ? 0 : ty, }; }); }, []); const resetView = useCallback(() => { setSnap(true); setView(IDENTITY); }, []); useEffect(() => { if (!snap) return; let inner = 0; const outer = requestAnimationFrame(() => { inner = requestAnimationFrame(() => setSnap(false)); }); return () => { cancelAnimationFrame(outer); cancelAnimationFrame(inner); }; }, [snap]); useEffect(() => { setView(IDENTITY); setPanning(false); dragRef.current = null; }, [index, current?.src]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); return; } if (e.key === "ArrowLeft") { goPrev(); return; } if (e.key === "ArrowRight") { goNext(); return; } if (e.key === "+" || e.key === "=") { e.preventDefault(); zoomBy(ZOOM_STEP); return; } if (e.key === "-" || e.key === "_") { e.preventDefault(); zoomBy(1 / ZOOM_STEP); return; } if (e.key === "0") { e.preventDefault(); resetView(); return; } if (e.key === "r" || e.key === "R") { e.preventDefault(); setView((v) => ({ ...v, rotation: v.rotation + (e.shiftKey ? -90 : 90), tx: 0, ty: 0, })); return; } if (e.key === "i" || e.key === "I") { e.preventDefault(); setView((v) => ({ ...v, invert: !v.invert })); return; } if (e.key === "h" || e.key === "H") { e.preventDefault(); setView((v) => ({ ...v, flipX: !v.flipX })); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, goPrev, goNext, zoomBy, resetView]); // 锁背景滚动,但补上滚动条宽度,避免开关图箱时页面左右跳 useEffect(() => { const html = document.documentElement; const { body } = document; const scrollbar = Math.max(0, window.innerWidth - html.clientWidth); const prevOverflow = body.style.overflow; const prevPaddingRight = body.style.paddingRight; body.style.overflow = "hidden"; if (scrollbar > 0) { const currentPad = parseFloat(getComputedStyle(body).paddingRight) || 0; body.style.paddingRight = `${currentPad + scrollbar}px`; } return () => { body.style.overflow = prevOverflow; body.style.paddingRight = prevPaddingRight; }; }, []); useEffect(() => { const el = rootRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); const stage = stageRef.current; const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; if (!stage) { zoomBy(factor); return; } const rect = stage.getBoundingClientRect(); zoomBy(factor, { x: e.clientX - rect.left - rect.width / 2, y: e.clientY - rect.top - rect.height / 2, }); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, [zoomBy]); const onPointerDown = (e: ReactPointerEvent) => { if (e.button !== 0) return; if (viewRef.current.scale <= 1.01) return; e.currentTarget.setPointerCapture(e.pointerId); dragRef.current = { x: e.clientX, y: e.clientY, tx: viewRef.current.tx, ty: viewRef.current.ty, }; setPanning(true); }; const onPointerMove = (e: ReactPointerEvent) => { const drag = dragRef.current; if (!drag) return; setView((v) => ({ ...v, tx: drag.tx + (e.clientX - drag.x), ty: drag.ty + (e.clientY - drag.y), })); }; const endPan = (e: ReactPointerEvent) => { if (!dragRef.current) return; dragRef.current = null; setPanning(false); if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } }; const onDoubleClick = () => { setView((v) => v.scale > 1.01 ? { ...v, scale: 1, tx: 0, ty: 0 } : { ...v, scale: 2, tx: 0, ty: 0 } ); }; if (!current || typeof document === "undefined") return null; const oddRot = Math.abs(view.rotation) % 180 !== 0; const sx = view.scale * (view.flipX ? -1 : 1); const zoomed = view.scale > 1.01; const zoomPct = `${Math.round(view.scale * 100)}%`; return createPortal(
{hasNav && ( <> )}
e.stopPropagation()}> {/* eslint-disable-next-line @next/next/no-img-element */} {current.alt?.trim()
e.stopPropagation()}> {current.alt ? (

{current.alt}

) : null}
zoomBy(1 / ZOOM_STEP)} > = MAX_SCALE} onClick={() => zoomBy(ZOOM_STEP)} > setView((v) => ({ ...v, rotation: v.rotation - 90, tx: 0, ty: 0, })) } > setView((v) => ({ ...v, rotation: v.rotation + 90, tx: 0, ty: 0, })) } > setView((v) => ({ ...v, flipX: !v.flipX }))} > setView((v) => ({ ...v, invert: !v.invert }))} > {hasNav ? ( <> {index + 1} / {total} ) : null}
, document.body ); }