feat(viewer,ui04): E04 interactions — pause reason set, click lock/drag cancel, manual scrub, keyboard semantics, destroy

- pauseReasons Set (hover/focus/pointer/offscreen/hidden/manual + userPaused via Space, no auto-resume); 500ms transient resume with re-evaluation
- pointerdown target lock; >8px drag suppresses click and scrubs via nudgeOffset (window-clamped); removed photos clear pending target
- keyboard: Space pause toggle, Escape collapse, ArrowUp/Down scrub; input fields untouched
- aria-live status announcements (zh/en); viewport touch-action none
- e2e: hover freeze/resume, popup deep link hit, drag cancel, keyboard, slot disable destroy

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 13:28:13 +08:00
parent 998565e439
commit 3912279889
5 changed files with 383 additions and 18 deletions
@@ -15,6 +15,7 @@ import {
advanceStream,
anchorRebuild,
createStreamState,
MAX_DOM_INSTANCES,
photoDisplayHeight,
PHOTO_GAP_PX,
removePhoto,
@@ -161,6 +162,38 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
if (!scrollable()) stopLoop();
}
/**
* 手动滚动(§4.3):外部(滚轮/拖动/键盘)直接平移当前窗口内容。夹取在
* 已渲染窗口范围内(有限真实列表语义——不重放全部历史);自动恢复时从
* 当前 offset 锚点继续(不回顶)。paused 状态下亦可用(reducedMotion 手动
* 查看路径)。超出窗口尾部时按需补窗(anchor 语义由下次 reanchor 收敛)。
*/
function nudgeOffset(deltaPx: number): void {
const s = state.value;
if (s === null || s.items.length === 0) return;
let total = 0;
for (const item of s.items) {
total += photoDisplayHeight(s.sizes[item.queueIdx], innerWidth) + PHOTO_GAP_PX;
}
const maxOffset = Math.max(0, total - viewportHeight);
// 头部上界:负向钳 0(已到最新);尾部下界钳 maxOffset(到窗口尾)。
s.offset = Math.max(0, Math.min(s.offset + deltaPx, maxOffset));
// 手动平移可能越过退役边界:复用 advance 的退役循环(速度 0 的纯夹取)。
let guard = 0;
while (
guard < MAX_DOM_INSTANCES &&
s.offset >= photoDisplayHeight(s.sizes[s.headIdx], innerWidth) + PHOTO_GAP_PX
) {
const headH =
photoDisplayHeight(s.sizes[s.headIdx], innerWidth) + PHOTO_GAP_PX;
s.offset -= headH;
s.headIdx = (s.headIdx + 1) % s.queue.length;
if (s.headIdx === 0) s.gen += 1;
guard += 1;
}
syncRender();
}
/**
* 当前与视口相交的照片(可见定义 §5.2:交集面积>0 且高度≥20px)——
* E05 气泡归属依据。纯计算,不触 DOM。
@@ -215,6 +248,7 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
photoById,
offsetView,
dropPhoto,
nudgeOffset,
visiblePhotoIds,
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
+2
View File
@@ -36,6 +36,8 @@ export default {
// UI-04 v2 vertical photo stream window
streamLabel: 'Event photo window',
collapseWindow: 'Collapse photo window',
streamPaused: 'Photo scrolling paused',
streamResumed: 'Photo scrolling resumed',
},
download: {
action: 'Download photo',
+2
View File
@@ -37,6 +37,8 @@ export default {
// —— UI-04 v2 垂直照片流窗 ——
streamLabel: '活动照片窗',
collapseWindow: '收起照片窗',
streamPaused: '照片滚动已暂停',
streamResumed: '照片滚动继续',
},
download: {
action: '下载图片',
+215 -18
View File
@@ -1,16 +1,17 @@
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
PhotoStreamViewport(relative/overflow hidden/手动模式滚动——E04)
Track(垂直照片实例带;useVerticalPhotoStream 逐帧 translateY 直写)
PhotoLink > img(原比例、宽满、block;真实 <a> 指向观众深链 §4.1)
HeartParticleLayer(E05:真实 likes.burst 粒子,pointer-events none)
CollapseButton(右上收起,§2.4 规格)
StateOverlay(空态;错误态随取数退避复用空窗不弹 UI,A11)
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
(异常宽高回退 4:3,§2.3);间距 3px;外窗(loader wrapper)负责整体圆角
与边框。循环副本(短队列一圈盖不满窗口)非首出实例对辅助技术隐藏(§3.2.7)。 -->
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2;交互层 §4:
· 暂停原因集合(Set,非互斥布尔):hiddenDocument/parentCollapsed(经 active)/
offscreen/hoverFinePointer/focusWithin/pointerActive/manualScroll/
reducedMotion/userPaused/insufficientContent——全部清空才自动滚动;
瞬态原因(指针/焦点/滚轮/拖动)离开后 500ms 恢复;键盘 Space 暂停属
用户显式暂停,不自动恢复(§4.2);
· 点击语义(§4.1):pointerdown 锁定目标 photoId;位移 >8px 或目标已
下线 → 取消点击;photoUrl 失效(换绑)亦拦截;
· 手动查看(§4.3):滚轮/拖动/上下方向键直接平移有限窗口(引擎
nudgeOffset,夹取窗口范围,不重放历史);Escape 收起;Space 暂停切换。
无 header/footer/页码/箭头/圆点(§2.2 硬性)。循环副本对辅助技术隐藏。 -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicEmbedPhoto } from '../../api/client';
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
@@ -39,12 +40,37 @@ const { t } = useI18n();
const viewportEl = ref<HTMLElement | null>(null);
const trackEl = ref<HTMLElement | null>(null);
// 运行闸门(§4.2 原因集的 E03 子集;E04 补 pointer/focus/manualScroll 等)。
const running = computed(() => props.active && !props.reducedMotion && props.photos.length > 1);
// ── 暂停原因集合(§4.2:Set 管理,自动滚动 = 全空)──
const pauseReasons = reactive(new Set<string>());
/** 用户显式键盘暂停(不自动恢复)。 */
const userPaused = ref(false);
/** 状态播报(aria-live;非布局占位,不常驻抢视觉)。 */
const statusText = ref('');
function setReason(reason: string, present: boolean): void {
if (present) pauseReasons.add(reason);
else pauseReasons.delete(reason);
}
const RESUME_DELAY_MS = 500; // 瞬态原因清空后的恢复延时(§4.2)
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
function announceStatus(paused: boolean): void {
statusText.value = paused ? t('embed.streamPaused') : t('embed.streamResumed');
}
const autoRunning = computed(
() =>
props.active &&
!props.reducedMotion &&
!userPaused.value &&
pauseReasons.size === 0 &&
props.photos.length > 1,
);
const photosRef = computed(() => props.photos);
const speedRef = computed(() => props.speed);
const runningRef = computed(() => running.value);
const runningRef = computed(() => autoRunning.value);
const stream = useVerticalPhotoStream({
photos: photosRef,
@@ -54,7 +80,7 @@ const stream = useVerticalPhotoStream({
running: runningRef,
});
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
const { items, photoById, dropPhoto } = stream;
const { items, photoById, dropPhoto, nudgeOffset } = stream;
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
function metaOf(photoId: string): PublicEmbedPhoto | null {
@@ -78,20 +104,174 @@ const firstKeyByPhoto = computed(() => {
return first;
});
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随指针交互补全)——
// ── 指针交互(§4.1/§4.2)──
const DRAG_CANCEL_PX = 8;
let pointerDownY: number | null = null;
let pointerMoved = 0;
/** pointerdown 锁定的目标 photoId(pointerup 校验后才放行点击)。 */
let lockedTarget: string | null = null;
/** 拖动抑制一次 click。 */
let suppressClick = false;
function scheduleResume(): void {
if (resumeTimer !== null) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
// 延时到点复评:原因已全清 → 恢复(原因重现则保持暂停)。
if (pauseReasons.size === 0) announceStatus(false);
}, RESUME_DELAY_MS);
}
function onPointerDown(event: PointerEvent): void {
if (event.pointerType === 'mouse' && event.button !== 0) return;
pointerDownY = event.clientY;
pointerMoved = 0;
const anchor = (event.target as HTMLElement).closest<HTMLElement>('a.stream-photo');
lockedTarget = anchor === null ? null : (anchor.dataset.photoId ?? null);
setReason('pointerActive', true);
}
function onPointerMove(event: PointerEvent): void {
if (pointerDownY === null) return;
const dy = event.clientY - pointerDownY;
pointerMoved = Math.max(pointerMoved, Math.abs(dy));
if (pointerMoved > DRAG_CANCEL_PX) {
suppressClick = true;
setReason('manualScroll', true);
// 拖动平移:手指上移(dy<0)→ 内容上移(offset 增),同向跟手。
nudgeOffset(-dy * 0.5);
pointerDownY = event.clientY;
}
}
function onPointerUp(): void {
pointerDownY = null;
setReason('pointerActive', false);
scheduleResume();
}
function onPhotoClick(photoId: string): void {
if (suppressClick) {
suppressClick = false;
return;
}
// 目标锁定校验(§4.1):未锁定(非照片按下)/ 换目标(移动到另一实例)/
// 目标已下线 → 取消。
if (!props.active) return;
if (lockedTarget !== null && lockedTarget !== photoId) return;
emit('open-photo', photoId);
}
// 照片下线 → 清待触发点击(引擎 dropPhoto 与 feed 移除路径都经此)。
function onPhotoRemoved(photoId: string): void {
if (lockedTarget === photoId) lockedTarget = null;
}
// ── 键盘语义(§4.2:聚焦时 Space 暂停切换 / Escape 收起 / 上下键手动)──
function onKeydown(event: KeyboardEvent): void {
const target = event.target as HTMLElement | null;
// 不抢输入字段(防御:iframe 内无输入,但保持通用语义)。
if (target !== null && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
if (event.key === 'Escape') {
event.preventDefault();
emit('collapse');
return;
}
if (event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
userPaused.value = !userPaused.value;
announceStatus(userPaused.value || pauseReasons.size > 0);
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
setReason('manualScroll', true);
nudgeOffset(event.key === 'ArrowDown' ? 60 : -60);
scheduleResume();
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
}
}
function onWheel(event: WheelEvent): void {
if (props.photos.length <= 1) return;
event.preventDefault();
setReason('manualScroll', true);
nudgeOffset(event.deltaY);
scheduleResume();
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
}
// ── 可见性/悬停/焦点(§4.2/§5.3)──
let hoverMedia: MediaQueryList | null = null;
let visibilityObserver: IntersectionObserver | null = null;
function onVisibilityChange(): void {
setReason('hiddenDocument', document.hidden);
}
function onPointerEnter(): void {
if (hoverMedia?.matches === true) setReason('hoverFinePointer', true);
}
function onPointerLeave(): void {
setReason('hoverFinePointer', false);
scheduleResume();
}
function onFocusIn(): void {
setReason('focusWithin', true);
}
function onFocusOut(): void {
setReason('focusWithin', false);
scheduleResume();
}
onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange);
onVisibilityChange();
if (typeof window.matchMedia === 'function') {
hoverMedia = window.matchMedia('(hover: hover) and (pointer: fine)');
}
if (typeof IntersectionObserver !== 'undefined' && viewportEl.value !== null) {
visibilityObserver = new IntersectionObserver(
(entries) => {
setReason('offscreen', !entries.some((entry) => entry.isIntersecting));
},
{ threshold: 0 },
);
visibilityObserver.observe(viewportEl.value);
}
});
onBeforeUnmount(() => {
setReason('destroyed', true);
document.removeEventListener('visibilitychange', onVisibilityChange);
visibilityObserver?.disconnect();
if (resumeTimer !== null) clearTimeout(resumeTimer);
});
</script>
<template>
<div ref="viewportEl" class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
<div
ref="viewportEl"
class="stream-viewport"
role="region"
tabindex="0"
:aria-label="t('embed.streamLabel')"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@pointerenter="onPointerEnter"
@pointerleave="onPointerLeave"
@focusin="onFocusIn"
@focusout="onFocusOut"
@keydown="onKeydown"
@wheel="onWheel"
>
<div ref="trackEl" class="stream-track">
<a
v-for="item of items"
:key="item.key"
class="stream-photo"
:data-photo-id="item.photoId"
:href="photoUrl(item.photoId)"
target="_blank"
rel="noopener noreferrer"
@@ -107,7 +287,7 @@ function onPhotoClick(photoId: string): void {
:style="{ aspectRatio: `1 / ${ratioOf(item.photoId)}` }"
decoding="async"
loading="eager"
@error="dropPhoto(item.photoId)"
@error="dropPhoto(item.photoId); onPhotoRemoved(item.photoId)"
>
</a>
</div>
@@ -115,6 +295,8 @@ function onPhotoClick(photoId: string): void {
<div v-if="photos.length === 0" class="stream-empty" role="note">
{{ t('embed.comingSoon') }}
</div>
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
<span class="stream-status" aria-live="polite">{{ statusText }}</span>
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
<button
type="button"
@@ -138,6 +320,12 @@ function onPhotoClick(photoId: string): void {
height: 100%;
overflow: hidden;
background: #10161e;
outline: none;
touch-action: none;
}
.stream-viewport:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.7);
outline-offset: -2px;
}
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口
* 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */
@@ -230,4 +418,13 @@ function onPhotoClick(photoId: string): void {
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
color: rgba(255, 255, 255, 0.72);
}
/* 状态播报:仅读屏消费,不出布局(§4.2「不常驻抢视觉」)。 */
.stream-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>