Files
jiang13-bbs/frontend/components/markdown/ImageLightbox.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

482 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<button
type="button"
className={`md-lightbox-tool${pressed ? " is-on" : ""}${className ? ` ${className}` : ""}`}
aria-label={label}
aria-pressed={pressed}
data-tip={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
/** 全屏图箱: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<View>(IDENTITY);
const [panning, setPanning] = useState(false);
const [snap, setSnap] = useState(false);
const viewRef = useRef(view);
viewRef.current = view;
const rootRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(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<HTMLImageElement>) => {
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<HTMLImageElement>) => {
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<HTMLImageElement>) => {
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(
<div
ref={rootRef}
className="md-lightbox"
role="dialog"
aria-modal="true"
aria-label="图片预览"
onClick={onClose}
>
<button
type="button"
className="md-lightbox-close"
aria-label="关闭"
onClick={onClose}
>
<X size={22} />
</button>
{hasNav && (
<>
<button
type="button"
className="md-lightbox-nav md-lightbox-prev"
aria-label="上一张"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
>
<ChevronLeft size={28} />
</button>
<button
type="button"
className="md-lightbox-nav md-lightbox-next"
aria-label="下一张"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
>
<ChevronRight size={28} />
</button>
</>
)}
<div
ref={stageRef}
className={`md-lightbox-stage${zoomed ? " is-zoomed" : ""}${panning ? " is-panning" : ""}`}
>
<figure className="md-lightbox-figure" onClick={(e) => e.stopPropagation()}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt?.trim() || "图片"}
className={`md-lightbox-img${oddRot ? " is-odd-rot" : ""}${snap ? " is-snap" : ""}`}
draggable={false}
style={{
transform: `translate(${view.tx}px, ${view.ty}px) rotate(${view.rotation}deg) scale(${sx}, ${view.scale})`,
filter: view.invert ? "invert(1)" : undefined,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPan}
onPointerCancel={endPan}
onDoubleClick={onDoubleClick}
/>
</figure>
</div>
<div className="md-lightbox-footer" onClick={(e) => e.stopPropagation()}>
{current.alt ? (
<p className="md-lightbox-caption">{current.alt}</p>
) : null}
<div className="md-lightbox-toolbar" role="toolbar" aria-label="图片工具">
<ToolBtn
label="缩小"
disabled={view.scale <= MIN_SCALE}
onClick={() => zoomBy(1 / ZOOM_STEP)}
>
<ZoomOut size={18} />
</ToolBtn>
<button
type="button"
className="md-lightbox-zoom-label"
aria-label="复位缩放"
data-tip="复位缩放"
onClick={() => setView((v) => ({ ...v, scale: 1, tx: 0, ty: 0 }))}
>
{zoomPct}
</button>
<ToolBtn
label="放大"
disabled={view.scale >= MAX_SCALE}
onClick={() => zoomBy(ZOOM_STEP)}
>
<ZoomIn size={18} />
</ToolBtn>
<span className="md-lightbox-sep" aria-hidden />
<ToolBtn
label="向左旋转"
onClick={() =>
setView((v) => ({
...v,
rotation: v.rotation - 90,
tx: 0,
ty: 0,
}))
}
>
<RotateCcw size={18} />
</ToolBtn>
<ToolBtn
label="向右旋转"
onClick={() =>
setView((v) => ({
...v,
rotation: v.rotation + 90,
tx: 0,
ty: 0,
}))
}
>
<RotateCw size={18} />
</ToolBtn>
<ToolBtn
label="水平翻转"
pressed={view.flipX}
onClick={() => setView((v) => ({ ...v, flipX: !v.flipX }))}
>
<FlipHorizontal size={18} />
</ToolBtn>
<ToolBtn
label="反色"
pressed={view.invert}
onClick={() => setView((v) => ({ ...v, invert: !v.invert }))}
>
<Contrast size={18} />
</ToolBtn>
<span className="md-lightbox-sep" aria-hidden />
<ToolBtn label="复位" disabled={isIdentity(view)} onClick={resetView}>
<Undo2 size={18} />
</ToolBtn>
{hasNav ? (
<>
<span className="md-lightbox-sep" aria-hidden />
<span className="md-lightbox-counter">
{index + 1} / {total}
</span>
</>
) : null}
</div>
</div>
</div>,
document.body
);
}