629 lines
17 KiB
Plaintext
629 lines
17 KiB
Plaintext
---
|
||
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> |