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)。 */