Files
astro-jiao77.cn/src/components/common/ImageViewer.astro
2025-09-30 02:09:26 +08:00

629 lines
17 KiB
Plaintext
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.
---
export interface Props {
src: string;
alt: string;
caption?: string;
className?: string;
width?: string | number;
height?: string | number;
aspectRatio?: string;
lazy?: boolean;
}
const {
src,
alt,
caption,
className = '',
width,
height,
aspectRatio,
lazy = true
} = Astro.props;
// 生成唯一ID
const imageId = `image-${Math.random().toString(36).substring(2, 11)}`;
---
<div class={`image-viewer ${className}`}>
<div
class="image-container"
style={aspectRatio ? `aspect-ratio: ${aspectRatio}` : ''}
onclick={`openImageModal('${imageId}')`}
>
<img
id={imageId}
src={src}
alt={alt}
width={width}
height={height}
loading={lazy ? 'lazy' : 'eager'}
class="viewer-image"
data-image-id={imageId}
data-full-src={src}
data-caption={caption ?? ''}
style="cursor: pointer;"
/>
</div>
{caption && (
<div class="image-caption">{caption}</div>
)}
</div>
<!-- 全屏模态框由全局脚本统一创建 -->
<script is:inline>
(function() {
if (window.imageViewerInitialized) return;
window.imageViewerInitialized = true;
const MODAL_ID = 'global-image-viewer-modal';
const MAX_SCALE = 6;
const MIN_SCALE = 1;
const state = {
modal: null,
overlay: null,
closeBtn: null,
prevBtn: null,
nextBtn: null,
counter: null,
modalImage: null,
modalCaption: null,
images: [],
currentIndex: -1,
scale: 1,
offsetX: 0,
offsetY: 0,
isPanning: false,
startX: 0,
startY: 0,
pointers: new Map(),
pinchStartDistance: 0,
pinchStartScale: 1,
};
function createModal() {
const modal = document.createElement('div');
modal.id = MODAL_ID;
modal.className = 'image-modal';
modal.innerHTML = `
<div class="modal-overlay"></div>
<button class="nav-button nav-prev" type="button" aria-label="上一张" hidden></button>
<button class="nav-button nav-next" type="button" aria-label="下一张" hidden></button>
<div class="modal-content">
<button class="close-btn" aria-label="关闭">×</button>
<img class="modal-image" alt="" draggable="false" />
<div class="modal-caption" hidden></div>
<div class="image-counter" hidden></div>
</div>
`;
document.body.appendChild(modal);
return modal;
}
function assignModalElements(modal) {
state.modal = modal;
state.overlay = modal.querySelector('.modal-overlay');
state.closeBtn = modal.querySelector('.close-btn');
state.prevBtn = modal.querySelector('.nav-prev');
state.nextBtn = modal.querySelector('.nav-next');
state.counter = modal.querySelector('.image-counter');
state.modalImage = modal.querySelector('.modal-image');
state.modalCaption = modal.querySelector('.modal-caption');
}
assignModalElements(document.getElementById(MODAL_ID) ?? createModal());
function collectImages() {
const candidates = Array.from(document.querySelectorAll('.viewer-image'));
const seen = new Set();
state.images = candidates.filter((node) => {
if (!(node instanceof HTMLImageElement)) return false;
if (!node.id) {
node.id = `viewer-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
}
if (seen.has(node.id)) return false;
seen.add(node.id);
if (!node.dataset.imageId) {
node.dataset.imageId = node.id;
}
if (!node.dataset.fullSrc) {
node.dataset.fullSrc = node.currentSrc || node.src;
}
return true;
});
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function clampOffsets() {
if (!state.modalImage) return;
const maxX = (state.scale - 1) * (state.modalImage.clientWidth / 2);
const maxY = (state.scale - 1) * (state.modalImage.clientHeight / 2);
if (maxX <= 0 || Number.isNaN(maxX)) {
state.offsetX = 0;
} else {
state.offsetX = clamp(state.offsetX, -maxX, maxX);
}
if (maxY <= 0 || Number.isNaN(maxY)) {
state.offsetY = 0;
} else {
state.offsetY = clamp(state.offsetY, -maxY, maxY);
}
}
function updateTransform() {
if (!state.modalImage) return;
state.modalImage.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`;
}
function updateCursor(mode) {
if (!state.modalImage) return;
if (mode === 'grabbing') {
state.modalImage.style.cursor = 'grabbing';
} else if (state.scale > 1) {
state.modalImage.style.cursor = 'grab';
} else {
state.modalImage.style.cursor = 'zoom-in';
}
}
function resetTransform() {
state.scale = 1;
state.offsetX = 0;
state.offsetY = 0;
state.isPanning = false;
state.startX = 0;
state.startY = 0;
state.pointers.clear();
state.pinchStartDistance = 0;
state.pinchStartScale = 1;
updateTransform();
updateCursor();
}
function toggleNavVisibility() {
const hasMultiple = state.images.length > 1;
if (state.prevBtn) {
state.prevBtn.hidden = !hasMultiple;
}
if (state.nextBtn) {
state.nextBtn.hidden = !hasMultiple;
}
if (state.counter) {
if (hasMultiple) {
state.counter.removeAttribute('hidden');
} else {
state.counter.setAttribute('hidden', '');
}
}
}
function showImage(index) {
if (!state.images.length) return;
const normalizedIndex = ((index % state.images.length) + state.images.length) % state.images.length;
const target = state.images[normalizedIndex];
if (!target || !(target instanceof HTMLImageElement)) return;
state.currentIndex = normalizedIndex;
const fullSrc = target.dataset.fullSrc || target.currentSrc || target.src;
if (state.modalImage) {
state.modalImage.src = fullSrc;
state.modalImage.alt = target.alt || '';
}
if (state.modalCaption) {
const captionText = target.dataset.caption || target.getAttribute('title') || '';
if (captionText) {
state.modalCaption.textContent = captionText;
state.modalCaption.removeAttribute('hidden');
} else {
state.modalCaption.textContent = '';
state.modalCaption.setAttribute('hidden', '');
}
}
if (state.counter) {
if (state.images.length > 1) {
state.counter.textContent = `${normalizedIndex + 1} / ${state.images.length}`;
} else {
state.counter.textContent = '';
}
}
toggleNavVisibility();
resetTransform();
}
function closeModal() {
if (!state.modal) return;
state.modal.classList.remove('active');
document.body.style.overflow = '';
resetTransform();
}
function navigate(delta) {
if (!state.modal?.classList.contains('active')) return;
if (state.images.length <= 1) return;
showImage(state.currentIndex + delta);
}
function onWheel(event) {
if (!state.modal?.classList.contains('active')) return;
event.preventDefault();
const factor = event.deltaY < 0 ? 1.1 : 0.9;
state.scale = clamp(state.scale * factor, MIN_SCALE, MAX_SCALE);
if (state.scale === 1) {
state.offsetX = 0;
state.offsetY = 0;
}
clampOffsets();
updateTransform();
updateCursor();
}
function onPointerDown(event) {
if (!state.modal?.classList.contains('active') || !state.modalImage) return;
event.preventDefault();
state.modalImage.setPointerCapture?.(event.pointerId);
state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (state.pointers.size === 1) {
state.isPanning = state.scale > 1;
state.startX = event.clientX - state.offsetX;
state.startY = event.clientY - state.offsetY;
if (state.isPanning) {
updateCursor('grabbing');
}
} else if (state.pointers.size === 2) {
const points = Array.from(state.pointers.values());
state.pinchStartDistance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
state.pinchStartScale = state.scale;
state.isPanning = false;
}
}
function onPointerMove(event) {
if (!state.modal?.classList.contains('active') || !state.pointers.has(event.pointerId)) return;
state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (state.pointers.size === 1 && state.isPanning && state.scale > 1) {
event.preventDefault();
state.offsetX = event.clientX - state.startX;
state.offsetY = event.clientY - state.startY;
clampOffsets();
updateTransform();
} else if (state.pointers.size >= 2) {
const points = Array.from(state.pointers.values()).slice(0, 2);
const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
if (state.pinchStartDistance > 0) {
event.preventDefault();
const nextScale = state.pinchStartScale * (distance / state.pinchStartDistance);
state.scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
if (state.scale === 1) {
state.offsetX = 0;
state.offsetY = 0;
}
clampOffsets();
updateTransform();
updateCursor();
}
}
}
function onPointerEnd(event) {
if (!state.pointers.has(event.pointerId)) return;
event.preventDefault();
state.modalImage?.releasePointerCapture?.(event.pointerId);
state.pointers.delete(event.pointerId);
if (state.pointers.size === 0) {
state.isPanning = false;
updateCursor();
} else if (state.pointers.size === 1) {
const remaining = Array.from(state.pointers.values())[0];
state.startX = remaining.x - state.offsetX;
state.startY = remaining.y - state.offsetY;
}
}
function onDoubleClick() {
if (!state.modal?.classList.contains('active')) return;
if (state.scale > 1.2) {
state.scale = 1;
state.offsetX = 0;
state.offsetY = 0;
} else {
state.scale = 2;
}
clampOffsets();
updateTransform();
updateCursor();
}
state.overlay?.addEventListener('click', closeModal);
state.closeBtn?.addEventListener('click', closeModal);
state.prevBtn?.addEventListener('click', () => navigate(-1));
state.nextBtn?.addEventListener('click', () => navigate(1));
state.modalImage?.addEventListener('wheel', onWheel, { passive: false });
state.modalImage?.addEventListener('pointerdown', onPointerDown);
state.modalImage?.addEventListener('pointermove', onPointerMove);
state.modalImage?.addEventListener('pointerup', onPointerEnd);
state.modalImage?.addEventListener('pointercancel', onPointerEnd);
state.modalImage?.addEventListener('dblclick', onDoubleClick);
document.addEventListener('keydown', (event) => {
if (!state.modal?.classList.contains('active')) return;
if (event.key === 'Escape') {
event.preventDefault();
closeModal();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
navigate(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
navigate(1);
} else if (event.key === 'Home') {
event.preventDefault();
if (state.images.length) showImage(0);
} else if (event.key === 'End') {
event.preventDefault();
if (state.images.length) showImage(state.images.length - 1);
}
});
const observer = new MutationObserver(() => {
collectImages();
toggleNavVisibility();
});
observer.observe(document.body, { childList: true, subtree: true });
collectImages();
toggleNavVisibility();
window.openImageModal = function(imageId) {
collectImages();
if (!state.images.length) return;
const targetIndex = state.images.findIndex((img) => img.dataset.imageId === imageId || img.id === imageId);
if (targetIndex === -1) return;
showImage(targetIndex);
state.modal?.classList.add('active');
document.body.style.overflow = 'hidden';
};
window.closeImageModal = closeModal;
console.log('ImageViewer global modal with gallery & gestures ready');
})();
</script>
<style>
.image-viewer {
margin: 1.5rem 0;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
transition: all 0.3s ease;
}
.image-viewer:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.image-container {
position: relative;
overflow: hidden;
cursor: pointer;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.viewer-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
display: block;
}
.image-container:hover .viewer-image {
transform: scale(1.05);
}
.image-caption {
padding: 1rem;
text-align: center;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(15px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: #374151;
font-size: 0.9rem;
font-style: italic;
}
/* 模态框样式 */
:global(.image-modal) {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
:global(.image-modal.active) {
opacity: 1;
visibility: visible;
}
:global(.image-modal .modal-overlay) {
position: absolute;
inset: 0;
background: rgba(11, 18, 33, 0.85);
backdrop-filter: blur(30px);
}
:global(.image-modal .modal-content) {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(1.5rem, 4vw, 3rem);
box-sizing: border-box;
transform: scale(0.95);
transition: transform 0.3s ease;
}
:global(.image-modal.active .modal-content) {
transform: scale(1);
}
:global(.image-modal .close-btn) {
position: absolute;
top: clamp(1rem, 3vw, 2.5rem);
right: clamp(1rem, 3vw, 2.5rem);
width: 3rem;
height: 3rem;
background: rgba(255, 255, 255, 0.18);
color: #f9fafb;
border: none;
border-radius: 999px;
font-size: 2rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.35);
z-index: 1;
}
:global(.image-modal .close-btn:hover) {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
:global(.image-modal .modal-image) {
width: 100%;
height: auto;
max-width: min(1200px, 95vw);
max-height: calc(100vh - clamp(6rem, 12vh, 10rem));
object-fit: contain;
border-radius: 1rem;
box-shadow: 0 25px 60px rgba(15, 23, 42, 0.45);
touch-action: none;
user-select: none;
cursor: zoom-in;
transition: transform 0.12s ease-out;
}
:global(.image-modal .nav-button) {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: clamp(3rem, 4vw, 4.5rem);
height: clamp(3rem, 4vw, 4.5rem);
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
color: #f8fafc;
font-size: clamp(1.8rem, 3vw, 2.4rem);
cursor: pointer;
backdrop-filter: blur(12px);
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.45);
transition: transform 0.2s ease, background 0.2s ease;
z-index: 1;
}
:global(.image-modal .nav-button:hover) {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-50%) scale(1.05);
}
:global(.image-modal .nav-button:active) {
transform: translateY(-50%) scale(0.95);
}
:global(.image-modal .nav-prev) {
left: clamp(0.75rem, 2vw, 2.5rem);
}
:global(.image-modal .nav-next) {
right: clamp(0.75rem, 2vw, 2.5rem);
}
:global(.image-modal .image-counter) {
position: absolute;
bottom: clamp(1.5rem, 3vh, 3rem);
left: 50%;
transform: translateX(-50%);
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
padding: 0.6rem 1.6rem;
border-radius: 999px;
font-size: 0.95rem;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.35);
letter-spacing: 0.08em;
}
:global(.image-modal .modal-caption) {
margin-top: clamp(1rem, 2.5vh, 2rem);
padding: clamp(0.75rem, 2vw, 1.5rem) clamp(1rem, 3vw, 2rem);
border-radius: 999px;
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
font-size: clamp(0.9rem, 2vw, 1.05rem);
font-style: italic;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.35);
text-align: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
:global(.image-modal) {
padding: 1rem;
}
:global(.image-modal .modal-image) {
max-width: 95vw;
max-height: 75vh;
}
:global(.image-modal .nav-button) {
width: 2.75rem;
height: 2.75rem;
font-size: 1.75rem;
}
}
/* 加载动画 */
.viewer-image {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>