complete many assets

This commit is contained in:
Jiao77
2025-09-30 02:09:26 +08:00
parent 9c0051c92b
commit b6782746c4
23 changed files with 2915 additions and 436 deletions

View File

@@ -0,0 +1,629 @@
---
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>