1341 lines
31 KiB
Svelte
1341 lines
31 KiB
Svelte
<script>
|
||
import { m } from '$lib/paraglide/messages';
|
||
import { resolve } from '$app/paths';
|
||
import { getPhotoFileUrl } from '$lib/api/client';
|
||
import { goto } from '$app/navigation';
|
||
|
||
let { data } = $props();
|
||
|
||
/** @type {import('$lib/api/types').Photo|null} */
|
||
let photo = $derived(data.photo);
|
||
let currentIndex = $derived(data.currentIndex ?? 0);
|
||
let totalPhotos = $derived(data.totalPhotos ?? 0);
|
||
let hasPrev = $derived(data.hasPrev ?? false);
|
||
let hasNext = $derived(data.hasNext ?? false);
|
||
let prevPhotoId = $derived(data.prevPhotoId);
|
||
let nextPhotoId = $derived(data.nextPhotoId);
|
||
|
||
// 导航加载状态
|
||
let isNavigating = $state(false);
|
||
let loadError = $state(false);
|
||
|
||
// 渐进式加载状态
|
||
/** @type {'thumbnail' | 'preview' | 'full'} */
|
||
let loadStage = $state('thumbnail');
|
||
let isFullQualityLoaded = $state(false);
|
||
let loadProgress = $state(0);
|
||
let isFirstLoad = true;
|
||
|
||
// 图片尺寸信息
|
||
let imageNaturalWidth = $state(0);
|
||
let imageNaturalHeight = $state(0);
|
||
let containerWidth = $state(0);
|
||
let containerHeight = $state(0);
|
||
|
||
// 触摸滑动支持
|
||
let touchStartX = 0;
|
||
let touchEndX = 0;
|
||
const SWIPE_THRESHOLD = 50;
|
||
|
||
// ========== 缩放相关状态 ==========
|
||
let scale = $state(1);
|
||
let minScale = $state(1);
|
||
let maxScale = 4;
|
||
let translateX = $state(0);
|
||
let translateY = $state(0);
|
||
let isDragging = $state(false);
|
||
let dragStartX = 0;
|
||
let dragStartY = 0;
|
||
let isPinching = $state(false);
|
||
let pinchStartDistance = 0;
|
||
let pinchStartScale = 0;
|
||
|
||
// 双击缩放
|
||
let lastClickTime = 0;
|
||
let lastClickX = 0;
|
||
let lastClickY = 0;
|
||
|
||
// 动画帧 ID
|
||
let animationFrameId = null;
|
||
let initialScale = 1;
|
||
|
||
// 监听容器尺寸变化
|
||
function updateContainerSize() {
|
||
const container = document.querySelector('.viewer-content');
|
||
if (container) {
|
||
containerWidth = container.clientWidth;
|
||
containerHeight = container.clientHeight;
|
||
updateMinScale();
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
updateContainerSize();
|
||
window.addEventListener('resize', updateContainerSize);
|
||
return () => window.removeEventListener('resize', updateContainerSize);
|
||
});
|
||
|
||
// 更新最小缩放比例
|
||
function updateMinScale() {
|
||
if (!imageNaturalWidth || !imageNaturalHeight || !containerWidth || !containerHeight) {
|
||
minScale = 0.25;
|
||
initialScale = 1;
|
||
return;
|
||
}
|
||
|
||
const fitWidth = containerWidth / imageNaturalWidth;
|
||
const fitHeight = containerHeight / imageNaturalHeight;
|
||
// 最小缩放:允许缩小到25%
|
||
minScale = 0.25;
|
||
|
||
// 只在首次加载时设置初始缩放
|
||
if (isFirstLoad) {
|
||
isFirstLoad = false;
|
||
// 初始缩放:适应容器但不超过100%(不放大)
|
||
initialScale = Math.min(Math.max(fitWidth, fitHeight), 1);
|
||
scale = initialScale;
|
||
targetScaleRef = initialScale;
|
||
}
|
||
}
|
||
|
||
// 平滑缩放的动画循环
|
||
function startAnimation() {
|
||
if (animationFrameId) return;
|
||
|
||
// 使用局部变量跟踪当前值,避免响应式更新延迟问题
|
||
let currentScale = scale;
|
||
let currentTranslateX = translateX;
|
||
let currentTranslateY = translateY;
|
||
|
||
function animate() {
|
||
const ease = 0.2;
|
||
let needsContinue = false;
|
||
|
||
if (Math.abs(currentScale - targetScaleRef) > 0.001) {
|
||
currentScale = currentScale + (targetScaleRef - currentScale) * ease;
|
||
scale = currentScale;
|
||
needsContinue = true;
|
||
} else {
|
||
// 动画完成,精确设置为目标值
|
||
scale = targetScaleRef;
|
||
}
|
||
|
||
if (Math.abs(currentTranslateX - targetTranslateXRef) > 0.5) {
|
||
currentTranslateX = currentTranslateX + (targetTranslateXRef - currentTranslateX) * ease;
|
||
translateX = currentTranslateX;
|
||
needsContinue = true;
|
||
} else {
|
||
translateX = targetTranslateXRef;
|
||
}
|
||
|
||
if (Math.abs(currentTranslateY - targetTranslateYRef) > 0.5) {
|
||
currentTranslateY = currentTranslateY + (targetTranslateYRef - currentTranslateY) * ease;
|
||
translateY = currentTranslateY;
|
||
needsContinue = true;
|
||
} else {
|
||
translateY = targetTranslateYRef;
|
||
}
|
||
|
||
clampTranslate();
|
||
|
||
if (needsContinue) {
|
||
animationFrameId = requestAnimationFrame(animate);
|
||
} else {
|
||
animationFrameId = null;
|
||
}
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(animate);
|
||
}
|
||
|
||
// 使用普通变量存储目标值(避免 $state 的复杂响应式)
|
||
let targetScaleRef = 1;
|
||
let targetTranslateXRef = 0;
|
||
let targetTranslateYRef = 0;
|
||
|
||
function goToPrevious() {
|
||
if (!hasPrev || isNavigating) return;
|
||
navigateToPhoto(prevPhotoId);
|
||
}
|
||
|
||
function goToNext() {
|
||
if (!hasNext || isNavigating) return;
|
||
navigateToPhoto(nextPhotoId);
|
||
}
|
||
|
||
async function navigateToPhoto(photoId) {
|
||
if (isNavigating) return;
|
||
|
||
isNavigating = true;
|
||
loadError = false;
|
||
loadStage = 'thumbnail';
|
||
isFullQualityLoaded = false;
|
||
loadProgress = 0;
|
||
imageNaturalWidth = 0;
|
||
imageNaturalHeight = 0;
|
||
isFirstLoad = true;
|
||
|
||
try {
|
||
await goto(resolve(`/photo/${photoId}`), { keepFocus: true });
|
||
} catch (error) {
|
||
console.error('Failed to navigate:', error);
|
||
loadError = true;
|
||
} finally {
|
||
isNavigating = false;
|
||
}
|
||
}
|
||
|
||
function handleClose() {
|
||
if (photo?.albumId) {
|
||
goto(resolve(`/album/${photo.albumId}`));
|
||
} else {
|
||
history.back();
|
||
}
|
||
}
|
||
|
||
function handleTouchStart(event) {
|
||
if (event.touches.length === 1) {
|
||
touchStartX = event.touches[0].clientX;
|
||
dragStartX = event.touches[0].clientX - translateX;
|
||
dragStartY = event.touches[0].clientY - translateY;
|
||
} else if (event.touches.length === 2) {
|
||
isPinching = true;
|
||
pinchStartDistance = getPinchDistance(event.touches);
|
||
pinchStartScale = scale;
|
||
}
|
||
}
|
||
|
||
function handleTouchMove(event) {
|
||
if (event.touches.length === 1 && !isPinching && scale > minScale + 0.1) {
|
||
event.preventDefault();
|
||
touchEndX = event.touches[0].clientX;
|
||
translateX = event.touches[0].clientX - dragStartX;
|
||
translateY = event.touches[0].clientY - dragStartY;
|
||
targetTranslateXRef = translateX;
|
||
targetTranslateYRef = translateY;
|
||
} else if (event.touches.length === 2 && isPinching) {
|
||
event.preventDefault();
|
||
const currentDistance = getPinchDistance(event.touches);
|
||
const newScale = pinchStartScale * (currentDistance / pinchStartDistance);
|
||
targetScaleRef = Math.max(minScale, Math.min(newScale, maxScale));
|
||
startAnimation();
|
||
}
|
||
}
|
||
|
||
function handleTouchEnd() {
|
||
isPinching = false;
|
||
|
||
if (touchStartX && touchEndX && scale <= minScale + 0.1) {
|
||
const diff = touchStartX - touchEndX;
|
||
if (Math.abs(diff) > SWIPE_THRESHOLD) {
|
||
if (diff > 0) {
|
||
goToNext();
|
||
} else {
|
||
goToPrevious();
|
||
}
|
||
}
|
||
}
|
||
|
||
touchStartX = 0;
|
||
touchEndX = 0;
|
||
}
|
||
|
||
function getPinchDistance(touches) {
|
||
const dx = touches[0].clientX - touches[1].clientX;
|
||
const dy = touches[0].clientY - touches[1].clientY;
|
||
return Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
|
||
// 键盘导航
|
||
$effect(() => {
|
||
function handleKeydown(event) {
|
||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||
return;
|
||
}
|
||
|
||
switch (event.key) {
|
||
case 'ArrowLeft':
|
||
if (scale <= minScale + 0.1) {
|
||
event.preventDefault();
|
||
goToPrevious();
|
||
}
|
||
break;
|
||
case 'ArrowRight':
|
||
if (scale <= minScale + 0.1) {
|
||
event.preventDefault();
|
||
goToNext();
|
||
}
|
||
break;
|
||
case 'Escape':
|
||
event.preventDefault();
|
||
if (scale > minScale) {
|
||
resetZoom();
|
||
} else {
|
||
handleClose();
|
||
}
|
||
break;
|
||
case ' ':
|
||
event.preventDefault();
|
||
if (loadStage !== 'full' && photo?.id) {
|
||
upgradeToFullQuality();
|
||
}
|
||
break;
|
||
case '+':
|
||
case '=':
|
||
event.preventDefault();
|
||
zoomIn();
|
||
break;
|
||
case '-':
|
||
event.preventDefault();
|
||
zoomOut();
|
||
break;
|
||
case '0':
|
||
event.preventDefault();
|
||
resetZoom();
|
||
break;
|
||
}
|
||
}
|
||
|
||
const container = document.querySelector('.viewer-container');
|
||
if (container) {
|
||
container.addEventListener('keydown', handleKeydown);
|
||
container.focus();
|
||
}
|
||
|
||
return () => {
|
||
if (container) {
|
||
container.removeEventListener('keydown', handleKeydown);
|
||
}
|
||
};
|
||
});
|
||
|
||
// 智能预加载相邻照片
|
||
$effect(() => {
|
||
let cancelled = false;
|
||
let preloadTimeout;
|
||
|
||
preloadTimeout = setTimeout(() => {
|
||
if (cancelled) return;
|
||
|
||
if (hasNext && nextPhotoId) {
|
||
const nextImg = new Image();
|
||
nextImg.src = resolve(`/api/v1/photo/${nextPhotoId}/preview?w=800`);
|
||
nextImg.fetchPriority = 'low';
|
||
}
|
||
|
||
if (hasPrev && prevPhotoId) {
|
||
const prevImg = new Image();
|
||
prevImg.src = resolve(`/api/v1/photo/${prevPhotoId}/preview?w=800`);
|
||
prevImg.fetchPriority = 'low';
|
||
}
|
||
}, 300);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
clearTimeout(preloadTimeout);
|
||
};
|
||
});
|
||
|
||
// 渐进式加载
|
||
$effect(() => {
|
||
if (!photo || photo.mimeType?.startsWith('video/')) return;
|
||
|
||
let cancelled = false;
|
||
|
||
const previewTimeout = setTimeout(() => {
|
||
if (cancelled || loadStage !== 'thumbnail') return;
|
||
loadStage = 'preview';
|
||
}, 100);
|
||
|
||
const fullTimeout = setTimeout(() => {
|
||
if (cancelled || !isFullQualityLoaded) return;
|
||
upgradeToFullQuality();
|
||
}, 800);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
clearTimeout(previewTimeout);
|
||
clearTimeout(fullTimeout);
|
||
};
|
||
});
|
||
|
||
function upgradeToFullQuality() {
|
||
if (!photo || loadStage === 'full' || photo.mimeType?.startsWith('video/')) return;
|
||
|
||
loadStage = 'full';
|
||
isFullQualityLoaded = true;
|
||
|
||
const fullResImg = new Image();
|
||
fullResImg.src = resolve(getPhotoFileUrl(photo.id));
|
||
fullResImg.fetchPriority = 'high';
|
||
|
||
fullResImg.onprogress = (event) => {
|
||
if (event.lengthComputable) {
|
||
loadProgress = Math.round((event.loaded / event.total) * 100);
|
||
}
|
||
};
|
||
|
||
fullResImg.onload = () => {
|
||
loadProgress = 100;
|
||
const imgElement = document.querySelector('.viewer-content img');
|
||
if (imgElement) {
|
||
imgElement.src = resolve(getPhotoFileUrl(photo.id));
|
||
imgElement.srcset = '';
|
||
imgElement.sizes = '100vw';
|
||
}
|
||
setTimeout(updateMinScale, 50);
|
||
};
|
||
|
||
fullResImg.onerror = () => {
|
||
console.error('Failed to load full resolution image');
|
||
loadProgress = 0;
|
||
loadStage = 'preview';
|
||
};
|
||
}
|
||
|
||
function handleImageLoad(event) {
|
||
isNavigating = false;
|
||
const img = event.target;
|
||
imageNaturalWidth = img.naturalWidth;
|
||
imageNaturalHeight = img.naturalHeight;
|
||
setTimeout(() => {
|
||
updateContainerSize();
|
||
// 只在首次加载时重置缩放
|
||
if (isFirstLoad) {
|
||
resetZoom();
|
||
}
|
||
}, 10);
|
||
}
|
||
|
||
function handleImageError() {
|
||
isNavigating = false;
|
||
loadError = true;
|
||
}
|
||
|
||
function getImageSrc() {
|
||
if (!photo || photo.mimeType?.startsWith('video/')) return '';
|
||
|
||
if (loadStage === 'full') {
|
||
return resolve(getPhotoFileUrl(photo.id));
|
||
}
|
||
|
||
const previewSrc = resolve(`/api/v1/photo/${photo.id}/preview`);
|
||
if (loadStage === 'preview') {
|
||
return `${previewSrc}?w=1200`;
|
||
}
|
||
|
||
return `${previewSrc}?w=400`;
|
||
}
|
||
|
||
function getImageSrcSet() {
|
||
if (!photo || loadStage === 'full' || photo.mimeType?.startsWith('video/')) return '';
|
||
|
||
const previewSrc = resolve(`/api/v1/photo/${photo.id}/preview`);
|
||
if (loadStage === 'preview') {
|
||
return `${previewSrc}?w=800 800w, ${previewSrc}?w=1200 1200w, ${previewSrc}?w=1600 1600w`;
|
||
}
|
||
|
||
return `${previewSrc}?w=400 400w, ${previewSrc}?w=800 800w`;
|
||
}
|
||
|
||
function getImageSizes() {
|
||
if (!photo || photo.mimeType?.startsWith('video/')) return '';
|
||
return '100vw';
|
||
}
|
||
|
||
// ========== 缩放控制函数 ==========
|
||
|
||
function zoomIn() {
|
||
const currentPercent = Math.round(targetScaleRef * 100);
|
||
// 找到下一个更高的规整百分比
|
||
const steps = [25, 50, 75, 100, 125, 150, 175, 200, 250, 300, 400];
|
||
let nextPercent = currentPercent * 1.5;
|
||
for (const step of steps) {
|
||
if (step > currentPercent + 5) {
|
||
nextPercent = step;
|
||
break;
|
||
}
|
||
}
|
||
targetScaleRef = Math.min(nextPercent / 100, maxScale);
|
||
startAnimation();
|
||
}
|
||
|
||
function zoomOut() {
|
||
const currentPercent = Math.round(targetScaleRef * 100);
|
||
// 找到下一个更低的规整百分比
|
||
const steps = [400, 300, 250, 200, 175, 150, 125, 100, 75, 50, 25];
|
||
let nextPercent = currentPercent / 1.5;
|
||
for (const step of steps) {
|
||
if (step < currentPercent - 5) {
|
||
nextPercent = step;
|
||
break;
|
||
}
|
||
}
|
||
targetScaleRef = Math.max(nextPercent / 100, minScale);
|
||
startAnimation();
|
||
}
|
||
|
||
function resetZoom() {
|
||
isFirstLoad = false;
|
||
targetScaleRef = initialScale;
|
||
targetTranslateXRef = 0;
|
||
targetTranslateYRef = 0;
|
||
startAnimation();
|
||
}
|
||
|
||
// 双击缩放
|
||
function handleImageClick(event) {
|
||
const now = Date.now();
|
||
|
||
// 使用容器作为参考系计算坐标
|
||
const container = document.querySelector('.viewer-content');
|
||
if (!container) return;
|
||
|
||
const rect = container.getBoundingClientRect();
|
||
const x = event.clientX - rect.left;
|
||
const y = event.clientY - rect.top;
|
||
|
||
if (
|
||
now - lastClickTime < 300 &&
|
||
Math.abs(event.clientX - lastClickX) < 50 &&
|
||
Math.abs(event.clientY - lastClickY) < 50
|
||
) {
|
||
if (targetScaleRef > initialScale + 0.01) {
|
||
resetZoom();
|
||
} else {
|
||
const zoomLevel = Math.min(maxScale, 2.5);
|
||
zoomToPoint(x, y, zoomLevel);
|
||
}
|
||
} else {
|
||
lastClickTime = now;
|
||
lastClickX = event.clientX;
|
||
lastClickY = event.clientY;
|
||
}
|
||
}
|
||
|
||
function zoomToPoint(pointX, pointY, zoomLevel) {
|
||
const currentScale = targetScaleRef;
|
||
const currentTranslateX = targetTranslateXRef;
|
||
const currentTranslateY = targetTranslateYRef;
|
||
|
||
// 点击点相对于容器中心的偏移
|
||
const dx = pointX - containerWidth / 2;
|
||
const dy = pointY - containerHeight / 2;
|
||
|
||
// 缩放比例变化
|
||
const scaleRatio = zoomLevel / currentScale;
|
||
|
||
// 计算新的平移,使点击点保持在容器中的相同位置
|
||
targetTranslateXRef = dx * (1 - scaleRatio) + currentTranslateX * scaleRatio;
|
||
targetTranslateYRef = dy * (1 - scaleRatio) + currentTranslateY * scaleRatio;
|
||
targetScaleRef = zoomLevel;
|
||
|
||
startAnimation();
|
||
}
|
||
|
||
// 鼠标滚轮缩放
|
||
function handleWheel(event) {
|
||
if (event.ctrlKey || event.metaKey) {
|
||
event.preventDefault();
|
||
const delta = event.deltaY > 0 ? 0.9 : 1.1;
|
||
const currentScale = targetScaleRef;
|
||
const newScale = Math.max(minScale, Math.min(currentScale * delta, maxScale));
|
||
|
||
if (Math.abs(newScale - currentScale) > 0.001) {
|
||
// 使用容器作为参考系计算坐标
|
||
const container = document.querySelector('.viewer-content');
|
||
if (container) {
|
||
const rect = container.getBoundingClientRect();
|
||
const x = event.clientX - rect.left;
|
||
const y = event.clientY - rect.top;
|
||
zoomToPoint(x, y, newScale);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 鼠标拖拽
|
||
function handleMouseDown(event) {
|
||
if (scale > minScale + 0.1 && event.button === 0) {
|
||
isDragging = true;
|
||
dragStartX = event.clientX - translateX;
|
||
dragStartY = event.clientY - translateY;
|
||
}
|
||
}
|
||
|
||
function handleMouseMove(event) {
|
||
if (isDragging) {
|
||
translateX = event.clientX - dragStartX;
|
||
translateY = event.clientY - dragStartY;
|
||
targetTranslateXRef = translateX;
|
||
targetTranslateYRef = translateY;
|
||
}
|
||
}
|
||
|
||
function handleMouseUp() {
|
||
isDragging = false;
|
||
}
|
||
|
||
function handleMouseLeave() {
|
||
isDragging = false;
|
||
}
|
||
|
||
// 限制平移范围
|
||
function clampTranslate() {
|
||
if (!imageNaturalWidth || !imageNaturalHeight) return;
|
||
|
||
const scaledWidth = imageNaturalWidth * targetScaleRef;
|
||
const scaledHeight = imageNaturalHeight * targetScaleRef;
|
||
|
||
const maxX = Math.max(0, (scaledWidth - containerWidth) / 2);
|
||
const maxY = Math.max(0, (scaledHeight - containerHeight) / 2);
|
||
|
||
targetTranslateXRef = Math.max(-maxX, Math.min(maxX, targetTranslateXRef));
|
||
targetTranslateYRef = Math.max(-maxY, Math.min(maxY, targetTranslateYRef));
|
||
}
|
||
|
||
// 获取当前缩放百分比显示
|
||
function getZoomPercent() {
|
||
return Math.round(scale * 100);
|
||
}
|
||
|
||
// 图片样式
|
||
function getImageStyle() {
|
||
const styles = {
|
||
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
||
transformOrigin: 'center center',
|
||
transition: isDragging || isPinching ? 'none' : 'transform 0.1s ease-out',
|
||
cursor: scale > minScale + 0.1 ? 'grab' : 'default'
|
||
};
|
||
return Object.entries(styles)
|
||
.map(([key, value]) => `${key}: ${value}`)
|
||
.join('; ');
|
||
}
|
||
|
||
function getImageWrapperStyle() {
|
||
const styles = {
|
||
cursor: isDragging ? 'grabbing' : scale > minScale + 0.1 ? 'grab' : 'default',
|
||
overflow: 'hidden'
|
||
};
|
||
return Object.entries(styles)
|
||
.map(([key, value]) => `${key}: ${value}`)
|
||
.join('; ');
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{photo ? photo.fileName : m.loading()}</title>
|
||
<meta
|
||
name="viewport"
|
||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||
/>
|
||
</svelte:head>
|
||
|
||
<div
|
||
class="viewer-container"
|
||
role="application"
|
||
tabindex="0"
|
||
ontouchstart={handleTouchStart}
|
||
ontouchmove={handleTouchMove}
|
||
ontouchend={handleTouchEnd}
|
||
>
|
||
{#if !photo}
|
||
<div class="loading">
|
||
<div class="loading-spinner"></div>
|
||
<p>{m.loading()}</p>
|
||
</div>
|
||
{:else}
|
||
{#if isNavigating}
|
||
<div class="loading-indicator">
|
||
<div class="loading-spinner small"></div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if loadError}
|
||
<div class="error-toast">
|
||
<span>{m.load_failed()}</span>
|
||
<button
|
||
onclick={() => {
|
||
loadError = false;
|
||
loadStage = 'thumbnail';
|
||
}}>{m.retry()}</button
|
||
>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if loadStage === 'full' && loadProgress < 100}
|
||
<div class="progress-indicator">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: {loadProgress}%"></div>
|
||
</div>
|
||
<span class="progress-text">{loadProgress}%</span>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if loadStage !== 'full' && !isNavigating && photo && !photo.mimeType?.startsWith('video/')}
|
||
<button class="quality-upgrade-btn" onclick={upgradeToFullQuality} type="button">
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
width="16"
|
||
height="16"
|
||
>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="M21 21l-4.35-4.35" />
|
||
<path d="M11 8v6" />
|
||
<path d="M8 11h6" />
|
||
</svg>
|
||
<span>{m.view_original()}</span>
|
||
</button>
|
||
{/if}
|
||
|
||
<div class="viewer-header">
|
||
<a href={resolve(`/album/${photo.albumId}`)} class="back-link">
|
||
<span class="back-icon">←</span>
|
||
<span class="back-text">{m.back()}</span>
|
||
</a>
|
||
<span class="photo-index">
|
||
{currentIndex + 1} / {totalPhotos}
|
||
</span>
|
||
{#if photo && !photo.mimeType?.startsWith('video/')}
|
||
<span class="quality-indicator">
|
||
{#if loadStage === 'full'}
|
||
{m.original()}
|
||
{:else if loadStage === 'preview'}
|
||
{m.high_quality()}
|
||
{:else}
|
||
{m.thumbnail()}
|
||
{/if}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="viewer-content">
|
||
<button
|
||
class="nav-button prev"
|
||
onclick={goToPrevious}
|
||
disabled={!hasPrev || isNavigating}
|
||
aria-label={m.previous_photo()}
|
||
type="button"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M15 18l-6-6 6-6" />
|
||
</svg>
|
||
</button>
|
||
|
||
<div
|
||
class="image-wrapper"
|
||
style={getImageWrapperStyle()}
|
||
onmousedown={handleMouseDown}
|
||
onmousemove={handleMouseMove}
|
||
onmouseup={handleMouseUp}
|
||
onmouseleave={handleMouseLeave}
|
||
>
|
||
{#if photo.mimeType?.startsWith('video/')}
|
||
<video
|
||
controls
|
||
playsinline
|
||
class="fill-container"
|
||
onloadeddata={handleImageLoad}
|
||
onerror={handleImageError}
|
||
>
|
||
<source src={resolve(getPhotoFileUrl(photo.id))} type={photo.mimeType} />
|
||
</video>
|
||
{:else}
|
||
<img
|
||
src={getImageSrc()}
|
||
srcset={getImageSrcSet()}
|
||
sizes={getImageSizes()}
|
||
alt={photo.fileName}
|
||
style={getImageStyle()}
|
||
onload={handleImageLoad}
|
||
onerror={handleImageError}
|
||
decoding="async"
|
||
fetchpriority={loadStage === 'full' ? 'high' : 'auto'}
|
||
onclick={handleImageClick}
|
||
onwheel={handleWheel}
|
||
class="zoomable-image"
|
||
/>
|
||
{/if}
|
||
|
||
{#if targetScaleRef > initialScale + 0.01 || targetScaleRef < initialScale - 0.01}
|
||
<button class="reset-zoom-btn" onclick={resetZoom} type="button" aria-label="重置缩放">
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
width="20"
|
||
height="20"
|
||
>
|
||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||
<path d="M3 3v5h5" />
|
||
</svg>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<button
|
||
class="nav-button next"
|
||
onclick={goToNext}
|
||
disabled={!hasNext || isNavigating}
|
||
aria-label={m.next_photo()}
|
||
type="button"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 18l6-6-6-6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 缩放控制栏 -->
|
||
{#if photo && !photo.mimeType?.startsWith('video/')}
|
||
<div class="zoom-controls">
|
||
<button
|
||
onclick={zoomOut}
|
||
disabled={targetScaleRef <= minScale + 0.01}
|
||
type="button"
|
||
aria-label={m.zoom_out()}
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
width="20"
|
||
height="20"
|
||
>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="M21 21l-4.35-4.35" />
|
||
<path d="M8 11h6" />
|
||
</svg>
|
||
</button>
|
||
<span class="zoom-level">{getZoomPercent()}%</span>
|
||
<button
|
||
onclick={zoomIn}
|
||
disabled={targetScaleRef >= maxScale - 0.01}
|
||
type="button"
|
||
aria-label={m.zoom_in()}
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
width="20"
|
||
height="20"
|
||
>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="M21 21l-4.35-4.35" />
|
||
<path d="M11 8v6" />
|
||
<path d="M8 11h6" />
|
||
</svg>
|
||
</button>
|
||
<button onclick={resetZoom} type="button" aria-label={m.reset()}>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
width="20"
|
||
height="20"
|
||
>
|
||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||
<path d="M3 3v5h5" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="photo-info">
|
||
<div class="photo-details">
|
||
<h2>{photo.fileName}</h2>
|
||
<p class="meta">
|
||
{#if photo.fileSize}
|
||
<span>{Math.round(photo.fileSize / 1024)} KB</span>
|
||
{/if}
|
||
{#if photo.width && photo.height}
|
||
<span>{photo.width}×{photo.height}</span>
|
||
{/if}
|
||
{#if photo.createdAt}
|
||
<span>{new Date(photo.createdAt).toLocaleDateString()}</span>
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷键提示 -->
|
||
<div class="shortcut-hints">
|
||
<span>{m.double_click_zoom()}</span>
|
||
<span>{m.pinch_zoom()}</span>
|
||
<span>{m.keyboard_zoom()}</span>
|
||
<span>{m.reset_zoom()}</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.viewer-container {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #000;
|
||
color: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: var(--z-modal);
|
||
outline: none;
|
||
overflow: hidden;
|
||
touch-action: pan-y;
|
||
}
|
||
|
||
.viewer-container:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 3px solid var(--color-overlay-light);
|
||
border-top-color: var(--color-text);
|
||
border-radius: var(--radius-full);
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
.loading-spinner.small {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-width: 2px;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.loading p {
|
||
color: var(--color-text-tertiary);
|
||
font-size: var(--font-size-base);
|
||
margin: 0;
|
||
}
|
||
|
||
.loading-indicator {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 100;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.error-toast {
|
||
position: absolute;
|
||
top: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--color-error);
|
||
color: var(--color-text-inverse);
|
||
padding: var(--space-md) var(--space-lg);
|
||
border-radius: var(--radius-md);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
z-index: 101;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.error-toast button {
|
||
background: var(--color-overlay-light);
|
||
border: none;
|
||
color: var(--color-text-inverse);
|
||
padding: var(--space-xs) var(--space-md);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.error-toast button:hover {
|
||
background: var(--color-overlay);
|
||
}
|
||
|
||
.progress-indicator {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--color-overlay-dark);
|
||
padding: var(--space-md) var(--space-lg);
|
||
border-radius: var(--radius-lg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
z-index: 102;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.progress-bar {
|
||
width: 100%;
|
||
height: 6px;
|
||
background: var(--color-overlay-light);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
|
||
transition: width var(--transition-normal);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.progress-text {
|
||
color: var(--color-text-inverse);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
|
||
.quality-upgrade-btn {
|
||
position: absolute;
|
||
bottom: 120px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--color-primary);
|
||
color: var(--color-text-inverse);
|
||
border: none;
|
||
padding: var(--space-sm) var(--space-lg);
|
||
border-radius: var(--radius-md);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
cursor: pointer;
|
||
transition: all var(--transition-normal);
|
||
z-index: 101;
|
||
font-size: var(--font-size-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.quality-upgrade-btn:hover {
|
||
background: var(--color-primary-hover);
|
||
transform: translateX(-50%) scale(1.05);
|
||
}
|
||
|
||
.quality-upgrade-btn:active {
|
||
transform: translateX(-50%) scale(0.98);
|
||
}
|
||
|
||
.quality-upgrade-btn svg {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.viewer-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: var(--space-md) var(--space-md);
|
||
background: linear-gradient(to bottom, var(--color-overlay-dark), transparent);
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.back-link {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
color: var(--color-text-inverse);
|
||
text-decoration: none;
|
||
font-size: var(--font-size-base);
|
||
padding: var(--space-sm) var(--space-md);
|
||
background: var(--color-overlay-light);
|
||
border-radius: var(--radius-md);
|
||
transition: background var(--transition-normal);
|
||
}
|
||
|
||
.back-link:hover {
|
||
background: var(--color-overlay);
|
||
}
|
||
|
||
.back-icon {
|
||
font-size: var(--font-size-xl);
|
||
line-height: 1;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.back-text {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.photo-index {
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--font-size-sm);
|
||
background: var(--color-overlay-light);
|
||
padding: 0.35rem var(--space-md);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
|
||
.quality-indicator {
|
||
color: var(--color-primary);
|
||
font-size: var(--font-size-xs);
|
||
background: var(--color-primary-light);
|
||
padding: var(--space-xs) 0.625rem;
|
||
border-radius: var(--radius-sm);
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
|
||
.viewer-content {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.image-wrapper {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.image-wrapper img,
|
||
.image-wrapper video {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
transition: opacity var(--transition-slow);
|
||
user-select: none;
|
||
-webkit-user-drag: none;
|
||
}
|
||
|
||
.zoomable-image {
|
||
will-change: transform;
|
||
}
|
||
|
||
.fill-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.reset-zoom-btn {
|
||
position: absolute;
|
||
top: var(--space-md);
|
||
right: var(--space-md);
|
||
background: var(--color-overlay-dark);
|
||
color: var(--color-text-inverse);
|
||
border: none;
|
||
padding: var(--space-sm);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
transition: all var(--transition-normal);
|
||
z-index: 20;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.reset-zoom-btn:hover {
|
||
background: var(--color-primary);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.reset-zoom-btn:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.nav-button {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
background: var(--color-overlay-light);
|
||
color: var(--color-text-inverse);
|
||
border: none;
|
||
padding: var(--space-md);
|
||
cursor: pointer;
|
||
transition: all var(--transition-normal);
|
||
border-radius: var(--radius-full);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 5;
|
||
}
|
||
|
||
.nav-button svg {
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
|
||
.nav-button:hover:not(:disabled) {
|
||
background: var(--color-overlay);
|
||
transform: translateY(-50%) scale(1.1);
|
||
}
|
||
|
||
.nav-button:active:not(:disabled) {
|
||
transform: translateY(-50%) scale(0.95);
|
||
}
|
||
|
||
.nav-button:disabled {
|
||
opacity: 0.2;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.nav-button.prev {
|
||
left: var(--space-md);
|
||
}
|
||
|
||
.nav-button.next {
|
||
right: var(--space-md);
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
bottom: 60px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--color-overlay-dark);
|
||
padding: var(--space-sm) var(--space-md);
|
||
border-radius: var(--radius-lg);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
z-index: 101;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.zoom-controls button {
|
||
background: var(--color-overlay-light);
|
||
color: var(--color-text-inverse);
|
||
border: none;
|
||
padding: var(--space-sm);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
transition: all var(--transition-normal);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.zoom-controls button:hover:not(:disabled) {
|
||
background: var(--color-primary);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.zoom-controls button:active:not(:disabled) {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.zoom-controls button:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.zoom-level {
|
||
min-width: 50px;
|
||
text-align: center;
|
||
font-size: var(--font-size-sm);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-inverse);
|
||
}
|
||
|
||
.shortcut-hints {
|
||
position: absolute;
|
||
bottom: var(--space-md);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--color-overlay-dark);
|
||
padding: var(--space-sm) var(--space-md);
|
||
border-radius: var(--radius-md);
|
||
display: flex;
|
||
gap: var(--space-md);
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-tertiary);
|
||
z-index: 10;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.nav-button {
|
||
padding: var(--space-sm);
|
||
}
|
||
|
||
.nav-button svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.nav-button.prev {
|
||
left: var(--space-sm);
|
||
}
|
||
|
||
.nav-button.next {
|
||
right: var(--space-sm);
|
||
}
|
||
|
||
.shortcut-hints {
|
||
display: none;
|
||
}
|
||
|
||
.zoom-controls {
|
||
bottom: 140px;
|
||
}
|
||
}
|
||
|
||
.photo-info {
|
||
padding: var(--space-md);
|
||
background: linear-gradient(to top, var(--color-overlay-dark), transparent);
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.photo-details {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.photo-info h2 {
|
||
font-size: var(--font-size-base);
|
||
font-weight: var(--font-weight-semibold);
|
||
margin: 0 0 var(--space-sm) 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: var(--color-text-inverse);
|
||
}
|
||
|
||
.photo-info .meta {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-tertiary);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.photo-info .meta span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
</style>
|