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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user