Files
photo-live/apps/viewer/src/components/PhotoLightbox.vue
T

731 lines
22 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PhotoSequence } from '../feed/sequence';
import type { DownloadController } from '../download/useDownload';
import type { LikesController } from '../likes/useLikes';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
// 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过
// 末尾且 hasMore 时取下一页扩展)+ 滚轮/双指缩放与平移(受控 CSS transform,数学
// 见 viewer/zoom.ts)+ 签名过期静默重取(复用 sequence.refreshPhotoUrls)。
//
// 状态归属:序列数据/分页在 ActivityPage 创建的 PhotoSequence(共享 composable),
// 本组件只持视图态(缩放/平移、加载失败标记、扩展中标记);当前照片以 photoId
// (v-model)为准而非下标——序列追加/solo 归位/条目移除都会移动下标,photoId 键
// 控让重排自动保持指向同一张照片。
//
// 手势(Pointer Events,无第三方依赖):
// · 双指:捏合缩放(transform 不动点 = 手势中心)+ 中点漂移平移;
// · 单指且 scale>1:拖拽平移;scale=1:横向轻扫切换上一张/下一张;
// · 双击(鼠标 dblclick / 触控双 tap):复位缩放;
// · 滚轮(无需 ctrl):以光标为不动点缩放;Esc/关闭按钮/背景点击关闭。
//
// 下载(P4a Task 4):工具栏入口按相册两级开关渲染(控制器 canDownload /
// canDownloadOriginal;download prop 缺省不渲染——组件单测/降级组装)。原图
// 可下时为分体按钮(主按钮=浏览图 + ▾ 菜单两选项);微信内置浏览器由控制器
// 判定并改走引导浮层(长按保存 + 复制深链)。
//
// 关闭回位(滚动锚点)与 URL 同步由 ActivityPage 负责(见 ActivityPage 注释)。
const props = defineProps<{
sequence: PhotoSequence;
photoId: string;
/** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */
likes?: LikesController;
/** 下载控制器(P4a Task 4):缺省不渲染下载位(组件单测/降级组装)。 */
download?: DownloadController;
}>();
const emit = defineEmits<{
'update:photoId': [photoId: string];
/** 当前照片经刷新确认已下线(404):neighborIndex 为移除前下标,供回退邻居。 */
removed: [photoId: string, neighborIndex: number];
close: [];
}>();
const { t } = useI18n();
const photos = computed(() => props.sequence.photos);
const index = computed(() => photos.value.findIndex((photo) => photo.photoId === props.photoId));
const photo = computed(() => photos.value[index.value] ?? null);
const total = computed(() => photos.value.length);
const hasPrev = computed(() => index.value > 0);
// 末张且仍有下一页 → 允许继续向右(导航触发序列扩展)。
const hasNext = computed(() => index.value < total.value - 1 || props.sequence.hasMore);
// —— 视图状态(按 photoId;切换照片即复位) ——
/** 图片单次重试的缓存击穿键:重试按钮点击后 +1 强制 <img> 重挂载重触发加载。 */
const retryCount = ref(0);
const failedIds = reactive(new Set<string>());
const retriedIds = reactive(new Set<string>());
const extending = ref(false);
watch(
() => props.photoId,
() => {
failedIds.clear();
retriedIds.clear();
retryCount.value = 0;
Object.assign(zoom, resetZoom());
},
);
// —— 缩放/平移 ——
const zoom = reactive<ZoomState>({ ...resetZoom() });
const rootEl = ref<HTMLElement | null>(null);
const imgStyle = computed(() => ({
transform: `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.scale})`,
}));
/** 手势中心(相对图片未变换时中心的坐标):图片经 flex 居中,未变换中心 = 容器中心。 */
function center(): { x: number; y: number } {
const rect = rootEl.value?.getBoundingClientRect();
if (rect === undefined) return { x: 0, y: 0 };
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function onWheel(event: WheelEvent): void {
const { x, y } = center();
Object.assign(zoom, zoomAt({ ...zoom }, wheelFactor(event.deltaY), event.clientX - x, event.clientY - y));
}
function onDblClick(): void {
Object.assign(zoom, resetZoom());
}
// —— 指针手势(双指捏合 / 单指平移 / 单指轻扫 / 双 tap 复位) ——
const pointers = new Map<number, { x: number; y: number }>();
let pinchDistance = 0;
let pinchMid = { x: 0, y: 0 };
let swipe: { x: number; y: number; dx: number; dy: number } | null = null;
let lastTap = { time: 0, x: 0, y: 0 };
/** 手势期间发生过位移:抑制手势收尾时的背景 click 误关闭。 */
let dragged = false;
const SWIPE_MIN_DISTANCE = 50;
function pointerPair(): { a: { x: number; y: number }; b: { x: number; y: number } } | null {
if (pointers.size < 2) return null;
const [first, second] = pointers.values();
if (first === undefined || second === undefined) return null;
return { a: first, b: second };
}
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function onPointerDown(event: PointerEvent): void {
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
dragged = false;
// 触控双 tap:400ms 内同点两落 → 复位缩放。
if (event.pointerType === 'touch' && pointers.size === 1) {
const now = performance.now();
if (now - lastTap.time < 400 && Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) < 40) {
Object.assign(zoom, resetZoom());
lastTap = { time: 0, x: 0, y: 0 };
} else {
lastTap = { time: now, x: event.clientX, y: event.clientY };
}
}
const pair = pointerPair();
if (pair !== null) {
pinchDistance = distance(pair.a, pair.b);
pinchMid = { x: (pair.a.x + pair.b.x) / 2, y: (pair.a.y + pair.b.y) / 2 };
swipe = null;
} else if (zoom.scale <= MIN_SCALE) {
swipe = { x: event.clientX, y: event.clientY, dx: 0, dy: 0 };
}
}
function onPointerMove(event: PointerEvent): void {
const previous = pointers.get(event.pointerId);
if (previous === undefined) return;
const current = { x: event.clientX, y: event.clientY };
pointers.set(event.pointerId, current);
const pair = pointerPair();
if (pair !== null) {
// 双指:距离比 → 缩放因子(不动点 = 当前中点);中点位移 → 平移。
const nextDistance = distance(pair.a, pair.b);
const mid = { x: (pair.a.x + pair.b.x) / 2, y: (pair.a.y + pair.b.y) / 2 };
const { x, y } = center();
const factor = pinchDistance > 0 ? nextDistance / pinchDistance : 1;
Object.assign(zoom, zoomAt({ ...zoom }, factor, mid.x - x, mid.y - y));
Object.assign(zoom, panBy({ ...zoom }, mid.x - pinchMid.x, mid.y - pinchMid.y));
pinchDistance = nextDistance;
pinchMid = mid;
dragged = true;
return;
}
if (zoom.scale > MIN_SCALE) {
// 单指平移(scale>1)。
Object.assign(zoom, panBy({ ...zoom }, current.x - previous.x, current.y - previous.y));
dragged = true;
} else if (swipe !== null) {
swipe.dx = current.x - swipe.x;
swipe.dy = current.y - swipe.y;
if (Math.abs(swipe.dx) > 10 || Math.abs(swipe.dy) > 10) dragged = true;
}
}
/** 手势结束(抬起/取消/离开全屏层)。不使用 setPointerCapture:捕获会把后续
* click 重定向到捕获元素,按钮(关闭/切换)将收不到点击。 */
function onPointerEnd(event: PointerEvent): void {
pointers.delete(event.pointerId);
if (pointers.size < 2) pinchDistance = 0;
if (pointers.size === 1) {
// 双指 → 单指:剩余指针重新作为平移/轻扫基线。
const [remaining] = pointers.values();
if (remaining !== undefined) {
swipe = zoom.scale <= MIN_SCALE ? { x: remaining.x, y: remaining.y, dx: 0, dy: 0 } : null;
}
return;
}
if (pointers.size > 0) return;
const gesture = swipe;
swipe = null;
if (gesture === null) return;
// 以抬起坐标结算总位移(最后一帧 move 可能早于抬起点)。
gesture.dx = event.clientX - gesture.x;
gesture.dy = event.clientY - gesture.y;
if (Math.abs(gesture.dx) > SWIPE_MIN_DISTANCE && Math.abs(gesture.dx) > Math.abs(gesture.dy) * 1.2) {
if (gesture.dx < 0) void goTo(index.value + 1);
else void goTo(index.value - 1);
}
}
function onBackdropClick(): void {
if (dragged) return; // 手势收尾的 click 不关闭
emit('close');
}
// —— 键盘 ——
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
emit('close');
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
void goTo(index.value - 1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
void goTo(index.value + 1);
}
}
// —— 导航(保持列表浏览序列;越过末尾且 hasMore → 扩展) ——
async function goTo(targetIndex: number): Promise<void> {
if (targetIndex < 0 || targetIndex === index.value) return;
if (targetIndex >= total.value) {
if (!props.sequence.hasMore) return;
extending.value = true;
try {
await props.sequence.loadMore();
} finally {
extending.value = false;
}
if (targetIndex >= photos.value.length) return; // 翻页失败/无新增:留在原处
}
const target = photos.value[targetIndex];
if (target === undefined || target.photoId === props.photoId) return;
emit('update:photoId', target.photoId);
}
// 相邻预取(±1 browse URL)+ 临近末尾预拉下一页,让右切更顺滑。
watch(
() => [props.photoId, photos.value] as const,
() => {
if (index.value === -1) return;
for (const neighbor of [index.value - 1, index.value + 1]) {
const target = photos.value[neighbor];
if (target !== undefined) void preload(target.browse.url);
}
if (index.value >= photos.value.length - 2 && props.sequence.hasMore && !props.sequence.loadingMore) {
void props.sequence.loadMore();
}
},
{ immediate: true },
);
function preload(url: string): void {
const img = new Image();
img.src = url;
}
// —— 点赞(Task 10):乐观 +1 与心跳动画由 LikesController 承担 ——
function likeCurrent(): void {
if (photo.value === null || props.likes === undefined) return;
props.likes.like(photo.value.photoId);
}
// —— 下载(P4a Task 4):菜单开合随照片切换/关闭复位 ——
const downloadMenuOpen = ref(false);
watch(
() => props.photoId,
() => {
downloadMenuOpen.value = false;
},
);
/** 菜单选项点击:收起菜单后发起对应 variant 下载。 */
function pickDownload(variant: 'browse' | 'original'): void {
downloadMenuOpen.value = false;
void props.download?.download(variant);
}
/** 主按钮:无原图位时直下浏览图;有原图位时也直下浏览图(菜单提供两项)。 */
function downloadBrowse(): void {
downloadMenuOpen.value = false;
void props.download?.download('browse');
}
// —— 签名过期/下线处理 ——
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
async function onImgError(): Promise<void> {
const current = photo.value;
if (current === null) return;
const { photoId } = current;
if (retriedIds.has(photoId)) {
failedIds.add(photoId);
return;
}
retriedIds.add(photoId);
const neighborIndex = index.value;
const result = await props.sequence.refreshPhotoUrls(photoId);
if (result === 'replaced') return; // 新签名 URL 换绑后 <img> 自动重载
if (result === 'removed') {
if (props.photoId === photoId) emit('removed', photoId, neighborIndex);
return;
}
failedIds.add(photoId); // 网络等其他失败:收起图片,展示可重试占位
}
function retryFailed(): void {
const current = photo.value;
if (current === null) return;
failedIds.delete(current.photoId);
retriedIds.delete(current.photoId);
retryCount.value += 1; // 强制 <img> 重挂载,重新触发加载/错误链
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<div
v-if="photo !== null"
ref="rootEl"
class="lightbox"
role="dialog"
aria-modal="true"
:aria-label="t('viewer.title')"
@wheel.prevent="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerEnd"
@pointercancel="onPointerEnd"
@pointerleave="onPointerEnd"
@dblclick="onDblClick"
>
<div class="backdrop" @click="onBackdropClick" />
<img
v-if="!failedIds.has(photo.photoId)"
:key="`${photo.photoId}:${retryCount}`"
class="photo"
:src="photo.browse.url"
:alt="t('route.photoLabel')"
:style="imgStyle"
draggable="false"
@error="onImgError"
>
<div v-else class="img-failed" role="alert">
<p>{{ t('viewer.loadFailed') }}</p>
<button type="button" @click="retryFailed">{{ t('album.retry') }}</button>
</div>
<header class="bar">
<span class="counter">{{ t('viewer.counter', { current: index + 1, total }) }}</span>
<button
v-if="likes !== undefined"
type="button"
class="control like-button"
:aria-label="t('viewer.like')"
@click="likeCurrent"
>
<span :key="likes.popNonce(photo.photoId)" class="heart" aria-hidden="true">♥</span>
<span class="like-count">{{ likes.displayFor(photo) }}</span>
</button>
<span v-else class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
<!-- 下载(P4a Task 4):两级开关驱动——都关无入口;browse 单按钮;
original 可下 → 分体按钮(主=浏览图 + ▾ 菜单两项)。 -->
<div
v-if="download !== undefined && download.canDownload"
class="download-group"
>
<button
type="button"
class="control download"
:disabled="download.busy"
:aria-label="t('download.action')"
@click="downloadBrowse"
>
{{ download.busy ? t('download.busy') : t('download.action') }}
</button>
<button
v-if="download.canDownloadOriginal"
type="button"
class="control download-caret"
:aria-label="t('download.options')"
:aria-expanded="downloadMenuOpen"
@click="downloadMenuOpen = !downloadMenuOpen"
>
▾
</button>
<div v-if="downloadMenuOpen && download.canDownloadOriginal" class="download-menu" role="menu">
<button type="button" role="menuitem" @click="pickDownload('browse')">
{{ t('download.action') }}
</button>
<button type="button" role="menuitem" @click="pickDownload('original')">
{{ t('download.original') }}
</button>
</div>
</div>
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
✕
</button>
</header>
<button
type="button"
class="control nav prev"
:disabled="!hasPrev"
:aria-label="t('viewer.previous')"
@click="goTo(index - 1)"
>
‹
</button>
<button
type="button"
class="control nav next"
:disabled="!hasNext"
:aria-label="t('viewer.next')"
@click="goTo(index + 1)"
>
›
</button>
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案;可重点击重试)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
{{ t(download.failed) }}
</p>
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
<div
v-if="download !== undefined && download.wechatGuide"
class="wechat-guide"
role="dialog"
aria-modal="true"
:aria-label="t('download.wechatTitle')"
>
<div class="wechat-card">
<h2>{{ t('download.wechatTitle') }}</h2>
<p>{{ t('download.wechatHint') }}</p>
<!-- clipboard 不可用降级:展示可选中文本供手动长按复制。 -->
<p v-if="download.copyNotice === 'failed'" class="copy-fallback">{{ download.shareUrl() }}</p>
<div class="guide-actions">
<button type="button" class="guide-copy" @click="download.copyShareLink()">
{{ t('download.copyLink') }}
</button>
<span v-if="download.copyNotice === 'copied'" class="copy-status" role="status">
{{ t('download.copied') }}
</span>
<span v-else-if="download.copyNotice === 'failed'" class="copy-status" role="status">
{{ t('download.copyFailed') }}
</span>
<button type="button" class="guide-dismiss" @click="download.dismissWechatGuide()">
{{ t('download.dismiss') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 极简基线样式:视觉打磨留待 P4a。 */
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
user-select: none;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(17, 24, 39, 0.92);
}
.photo {
position: relative;
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center;
will-change: transform;
}
.img-failed {
position: relative;
color: #f3f4f6;
text-align: center;
}
.img-failed button {
margin-top: 8px;
border: 1px solid #4b5563;
border-radius: 6px;
background: transparent;
color: #e5e7eb;
padding: 4px 12px;
cursor: pointer;
}
.bar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
color: #f9fafb;
background: linear-gradient(rgba(0, 0, 0, 0.45), transparent);
}
.counter {
font-variant-numeric: tabular-nums;
}
.likes {
color: #d1d5db;
font-size: 0.875rem;
}
.like-button {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 12px;
font-size: 0.9rem;
border: 1px solid rgba(249, 250, 251, 0.35);
}
.like-button .heart {
color: #ef4444;
display: inline-block;
}
/* :key 重挂触发心跳放大;「减少动画」时禁用(数字仍即时跳动)。 */
.like-button .heart {
animation: heart-pop 400ms ease-out;
}
.like-count {
font-variant-numeric: tabular-nums;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
.viewer-reduced-motion .like-button .heart {
animation: none;
}
.control {
border: none;
background: rgba(17, 24, 39, 0.55);
color: #f9fafb;
cursor: pointer;
}
.control:disabled {
opacity: 0.35;
cursor: default;
}
.control.close {
margin-left: auto;
font-size: 1.1rem;
border-radius: 6px;
padding: 4px 10px;
}
/* —— 下载(P4a Task 4)—— */
.download-group {
position: relative;
display: inline-flex;
align-items: center;
}
.control.download {
border: 1px solid rgba(249, 250, 251, 0.35);
border-radius: 999px 0 0 999px;
padding: 4px 12px;
font-size: 0.9rem;
}
.control.download:disabled {
opacity: 0.55;
cursor: default;
}
.control.download-caret {
border: 1px solid rgba(249, 250, 251, 0.35);
border-left: none;
border-radius: 0 999px 999px 0;
padding: 4px 8px;
font-size: 0.8rem;
}
.download-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
display: grid;
min-width: 9em;
border-radius: 8px;
background: rgba(17, 24, 39, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
z-index: 2;
}
.download-menu button {
border: none;
background: transparent;
color: #f9fafb;
text-align: left;
padding: 8px 14px;
font-size: 0.9rem;
cursor: pointer;
}
.download-menu button:hover {
background: rgba(255, 255, 255, 0.08);
}
.download-failed {
position: absolute;
bottom: 52px;
left: 50%;
transform: translateX(-50%);
margin: 0;
border-radius: 8px;
background: rgba(185, 28, 28, 0.92);
color: #fef2f2;
padding: 6px 14px;
font-size: 0.875rem;
}
/* —— 微信引导浮层 —— */
.wechat-guide {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.72);
padding: 24px;
}
.wechat-card {
max-width: 420px;
border-radius: 12px;
background: #f9fafb;
color: #111827;
padding: 20px 24px;
}
.wechat-card h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.wechat-card p {
margin: 0 0 12px;
font-size: 0.9rem;
line-height: 1.6;
white-space: pre-line;
}
.copy-fallback {
user-select: all;
word-break: break-all;
background: #e5e7eb;
border-radius: 6px;
padding: 6px 8px;
}
.guide-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.guide-actions button {
border: none;
border-radius: 999px;
padding: 6px 16px;
font-size: 0.9rem;
cursor: pointer;
}
.guide-copy {
background: #1d4ed8;
color: #fff;
}
.guide-dismiss {
background: #e5e7eb;
color: #374151;
}
.copy-status {
font-size: 0.8rem;
color: #6b7280;
}
.control.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
line-height: 1;
border-radius: 999px;
width: 44px;
height: 44px;
}
.control.nav.prev {
left: 12px;
}
.control.nav.next {
right: 12px;
}
.loading {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
margin: 0;
color: #d1d5db;
}
</style>