feat(viewer,ui04): E03 continuous scroll engine — pure state machine, rAF orchestration, boundary continuity

- stream.ts: advanceStream (per-frame, dt cap 50ms), loop generation keys, window math (DOM<=16, queue<=30, 480px buffer), anchorRebuild (photo-position preserving), removePhoto, single-photo static
- useVerticalPhotoStream: rAF integration, direct transform writes (no per-frame Vue reactivity), low-frequency item sync, ResizeObserver re-anchor, stepForTest hook
- EmbedStream wired to engine; cycle copies aria-hidden; img error -> controlled drop
- tests: 14 unit cases (236 viewer total), e2e: 32px/2s speed, boundary continuity <2.5px, DOM<=16 over 20s, 32s recording with loop boundaries

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 13:19:21 +08:00
parent 1d1979f540
commit 998565e439
7 changed files with 914 additions and 23 deletions
+66 -21
View File
@@ -1,25 +1,26 @@
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
PhotoStreamViewport(relative/overflow hidden/手动模式滚动)
Track(垂直照片实例带,translateY 驱动——E03 接线)
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(loading/empty/error 按需)
StateOverlay(空态;错误态随取数退避复用空窗不弹 UI,A11)
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
(异常宽高回退 4:3 占位,§2.3);间距 3px;外窗(loader wrapper)负责整体
圆角与边框,内部不再加圆角。 -->
(异常宽高回退 4:3,§2.3);间距 3px;外窗(loader wrapper)负责整体圆角
与边框。循环副本(短队列一圈盖不满窗口)非首出实例对辅助技术隐藏(§3.2.7)。 -->
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicEmbedPhoto } from '../../api/client';
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
import { photoRatio } from '../../embed/stream';
const props = defineProps<{
photos: PublicEmbedPhoto[];
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
photoUrl: (photoId: string) => string;
/** 自动上滚速度(CSS px/s;reducedMotion/暂停时不动,E03 接线 rAF)。 */
/** 自动上滚速度(CSS px/s)。 */
speed: number;
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
reactions: boolean;
@@ -35,35 +36,78 @@ const emit = defineEmits<{
const { t } = useI18n();
const ratios = computed(() => props.photos.map((p) => photoRatio(p)));
const viewportEl = ref<HTMLElement | null>(null);
const trackEl = ref<HTMLElement | null>(null);
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随滚动接线补全)──
function onPhotoClick(photo: PublicEmbedPhoto): void {
// 运行闸门(§4.2 原因集的 E03 子集;E04 补 pointer/focus/manualScroll 等)。
const running = computed(() => props.active && !props.reducedMotion && props.photos.length > 1);
const photosRef = computed(() => props.photos);
const speedRef = computed(() => props.speed);
const runningRef = computed(() => running.value);
const stream = useVerticalPhotoStream({
photos: photosRef,
viewportEl,
trackEl,
speedPxPerSec: speedRef,
running: runningRef,
});
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
const { items, photoById, dropPhoto } = stream;
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
function metaOf(photoId: string): PublicEmbedPhoto | null {
return photoById.value.get(photoId) ?? null;
}
function ratioOf(photoId: string): number {
const photo = metaOf(photoId);
return photo === null ? 4 / 3 : photoRatio(photo);
}
/** 同 photoId 的非首次出现实例(循环副本):aria-hidden + tabindex 移除(§3.2.7)。 */
const firstKeyByPhoto = computed(() => {
const seen = new Set<string>();
const first = new Set<string>();
for (const item of items.value) {
if (!seen.has(item.photoId)) {
seen.add(item.photoId);
first.add(item.key);
}
}
return first;
});
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随指针交互补全)——
function onPhotoClick(photoId: string): void {
if (!props.active) return;
emit('open-photo', photo.photoId);
emit('open-photo', photoId);
}
</script>
<template>
<div class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
<div class="stream-track">
<div ref="viewportEl" class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
<div ref="trackEl" class="stream-track">
<a
v-for="(photo, i) in photos"
:key="photo.photoId"
v-for="item of items"
:key="item.key"
class="stream-photo"
:href="photoUrl(photo.photoId)"
:href="photoUrl(item.photoId)"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('embed.photoAlt', { n: i + 1 })"
@click.prevent="onPhotoClick(photo)"
:aria-hidden="!firstKeyByPhoto.has(item.key) || undefined"
:tabindex="firstKeyByPhoto.has(item.key) ? 0 : -1"
:aria-label="t('embed.photoAlt', { n: item.queueIdx + 1 })"
@click.prevent="onPhotoClick(item.photoId)"
>
<img
class="stream-photo__img"
:src="photo.thumb.url"
:alt="t('embed.photoAlt', { n: i + 1 })"
:style="{ aspectRatio: `1 / ${ratios[i]}` }"
:src="metaOf(item.photoId)?.thumb.url"
:alt="t('embed.photoAlt', { n: item.queueIdx + 1 })"
:style="{ aspectRatio: `1 / ${ratioOf(item.photoId)}` }"
decoding="async"
loading="eager"
@error="dropPhoto(item.photoId)"
>
</a>
</div>
@@ -126,6 +170,7 @@ function onPhotoClick(photo: PublicEmbedPhoto): void {
.stream-photo {
display: block;
width: 100%;
flex: none;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}