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
+250
View File
@@ -60,3 +60,253 @@ export function windowCount(
// 副本由编排层处理,本函数不放大队列)。
return count;
}
// ── E03 连续滚动引擎的纯状态机(§3.1/§3.2)──
// 引擎状态与推进/锚定全部收敛为纯函数:rAF 编排层(useVerticalPhotoStream)
// 只做「每帧积分 → DOM transform」「退役/补尾 → Vue 渲染列表」两件事。
/** 渲染实例:有限窗口内的一条照片引用(key 含循环代次防同照重复 key)。 */
export interface StreamRenderItem {
/** Vue :key 与 DOM 复用标识:`{photoId}#{generation}`。 */
key: string;
photoId: string;
/** 数据队列下标(循环取模)。 */
queueIdx: number;
/** 该实例的循环代次(每绕队列一周 +1)。 */
gen: number;
}
/** 引擎可变状态(advance/anchor 纯函数的输入输出载体)。 */
export interface StreamEngineState {
/** 队列(photoId 序,最新在前——与 feed 合并结果同序)。 */
queue: string[];
/** 各队列条目展示尺寸(与 queue 同长同序)。 */
sizes: StreamPhotoSize[];
/** 顶部渲染实例的队列下标。 */
headIdx: number;
/** 已滚过头条目顶部的像素(0 ≤ offset < 头条目高+缝)。 */
offset: number;
/** 当前循环代次(headIdx 回绕 0 时 +1,作新尾实例 gen)。 */
gen: number;
/** 有限窗口渲染实例(head 起,向前覆盖窗口+缓冲)。 */
items: StreamRenderItem[];
}
/** DOM 实例上限(§3.2.6:最多 16 个图片实例)。 */
export const MAX_DOM_INSTANCES = 16;
/** 数据队列上限(§3.2.6;feed 单次 ≤20,合并后截尾保序)。 */
export const MAX_QUEUE = 30;
/** 窗口上下缓冲(px):预加载余量(§3.3 当前+接下来 2 张的覆盖带)。 */
export const WINDOW_BUFFER_PX = 480;
/** 条目展示高(含缝):比例 × 内宽。 */
function entryHeight(size: StreamPhotoSize, innerWidth: number): number {
return photoDisplayHeight(size, innerWidth) + PHOTO_GAP_PX;
}
/** 覆盖窗口+缓冲所需的实例数(自 headIdx 起,循环计数;含极矮一轮不足的
* 整队情形——窗口副本语义由 items 天然形成:count 可 > queue.length)。 */
function coverageCount(
state: Pick<StreamEngineState, 'queue' | 'sizes' | 'headIdx'>,
innerWidth: number,
viewportHeight: number,
): number {
const queueLen = state.queue.length;
if (queueLen === 0) return 0;
const target = viewportHeight + WINDOW_BUFFER_PX;
let acc = 0;
for (let i = 0; i < MAX_DOM_INSTANCES; i++) {
acc += entryHeight(state.sizes[(state.headIdx + i) % queueLen], innerWidth);
if (acc >= target) return i + 1;
}
return MAX_DOM_INSTANCES;
}
/** 重建有限窗口(headIdx/gen 基准;覆盖不足时循环副本,副本 key 含代次)。
* 单张静态(§3.2.8):不复制刷屏,恒一个实例。 */
function rebuildItems(
state: StreamEngineState,
innerWidth: number,
viewportHeight: number,
): void {
const queueLen = state.queue.length;
if (queueLen === 0) {
state.items = [];
return;
}
if (queueLen === 1) {
state.items = [
{
key: `${state.queue[0]}#${state.gen}`,
photoId: state.queue[0],
queueIdx: 0,
gen: state.gen,
},
];
return;
}
const count = Math.max(1, coverageCount(state, innerWidth, viewportHeight));
const items: StreamRenderItem[] = [];
let gen = state.gen;
for (let i = 0; i < count; i++) {
const queueIdx = (state.headIdx + i) % queueLen;
// 实例的 gen:从 head 起向前,每回绕到更小下标(一圈)+1。
if (i > 0 && queueIdx === 0) gen += 1;
items.push({
key: `${state.queue[queueIdx]}#${gen}`,
photoId: state.queue[queueIdx],
queueIdx,
gen,
});
}
state.items = items;
}
export interface CreateStreamStateInput {
photos: Array<{ photoId: string } & StreamPhotoSize>;
innerWidth: number;
viewportHeight: number;
}
/** 由 feed 照片序列创建引擎状态(截尾 MAX_QUEUE;head=最新)。 */
export function createStreamState(input: CreateStreamStateInput): StreamEngineState {
const queue = input.photos.map((p) => p.photoId);
const sizes = input.photos.map((p) => ({ width: p.width, height: p.height }));
const state: StreamEngineState = {
queue,
sizes,
headIdx: 0,
offset: 0,
gen: 0,
items: [],
};
rebuildItems(state, input.innerWidth, input.viewportHeight);
return state;
}
export interface AdvanceResult {
/** 头条目是否退役(调用方据此重算渲染列表——低频,不逐帧)。 */
retired: boolean;
}
/** 单帧时间上限(ms):后台长停顿恢复不补跑(§3.1 deltaTime)。 */
export const MAX_FRAME_MS = 50;
/**
* 单帧推进(§3.1):offset += speed×dt(dt 上限 50ms,长停顿不补跑);
* 头条目完整滚出(offset ≥ 头条高+缝)即退役:offset 回扣、headIdx 前进、
* 跨界过程屏幕内容不变(不瞬移)。返回是否发生退役。
*/
export function advanceStream(
state: StreamEngineState,
dtMs: number,
speedPxPerSec: number,
innerWidth: number,
viewportHeight: number,
): AdvanceResult {
const queueLen = state.queue.length;
if (queueLen === 0) return { retired: false };
const dt = Math.min(dtMs, MAX_FRAME_MS);
state.offset += (speedPxPerSec * dt) / 1000;
let retired = false;
// 一帧内可能跨多条极矮条目:循环退役直至 offset 落回头条目内。
for (let guard = 0; guard < MAX_DOM_INSTANCES; guard++) {
const headH = entryHeight(state.sizes[state.headIdx], innerWidth);
if (state.offset < headH) break;
state.offset -= headH;
state.headIdx = (state.headIdx + 1) % queueLen;
if (state.headIdx === 0) state.gen += 1;
retired = true;
}
if (retired) rebuildItems(state, innerWidth, viewportHeight);
return { retired };
}
/** 多帧受控推进(测试/校准用):按 ≤50ms 帧分块累计 durationMs(与运行时
* rAF 逐帧语义一致;大 dt 不经单帧捷径绕过退役边界)。 */
export function advanceStreamFor(
state: StreamEngineState,
durationMs: number,
speedPxPerSec: number,
innerWidth: number,
viewportHeight: number,
): void {
let remaining = durationMs;
while (remaining > 0) {
const dt = Math.min(remaining, 50);
advanceStream(state, dt, speedPxPerSec, innerWidth, viewportHeight);
remaining -= dt;
}
}
/**
* 数据更新锚定(§6.2):以「首个与视口相交的 photoId + 图内偏移」为锚,
* 队列合并(幸存保序、新图入未显示区)后重建状态,保持锚照片屏幕位置不变
* (±2px 内,允许新尺寸重算)。锚照片被移除时回退到最近幸存条目。
*/
export function anchorRebuild(
state: StreamEngineState,
nextPhotos: Array<{ photoId: string } & StreamPhotoSize>,
innerWidth: number,
viewportHeight: number,
): void {
const queueLen = state.queue.length;
// ① 找锚:自 head 起首个「底边超过 offset」的实例(即视口顶可见者)。
let anchorPhotoId: string | null = null;
let anchorInPhoto = 0;
if (queueLen > 0) {
let acc = -state.offset;
for (let i = 0; i < state.items.length; i++) {
const item = state.items[i];
const h = entryHeight(state.sizes[item.queueIdx], innerWidth);
if (acc + h > 0) {
anchorPhotoId = item.photoId;
// 照片顶部在视口上方 acc(负值)→ 图内偏移 = -acc(已滚入部分)。
anchorInPhoto = Math.max(0, -acc);
break;
}
acc += h;
}
}
// ② 合并到新队列(幸存保序 + 新图追加——与 feed.mergePhotoQueue 同语义,
// 此处直接按 nextPhotos 服务端序重建,锚定保位置、新增不入显示区)。
const nextQueue = nextPhotos.map((p) => p.photoId);
const nextSizes = nextPhotos.map((p) => ({ width: p.width, height: p.height }));
state.queue = nextQueue;
state.sizes = nextSizes;
// ③ 重定位 head:锚照片仍存在 → head=锚(图内偏移保留,按新尺寸夹取);
// 已移除 → 保持原 headIdx 相对位置(夹取到新队列长度内)。
let headIdx = 0;
if (anchorPhotoId !== null) {
const idx = nextQueue.indexOf(anchorPhotoId);
if (idx >= 0) headIdx = idx;
else headIdx = Math.min(state.headIdx, Math.max(0, nextQueue.length - 1));
}
state.headIdx = headIdx;
const newHeadH =
nextQueue.length > 0 ? entryHeight(nextSizes[headIdx], innerWidth) - PHOTO_GAP_PX : 0;
state.offset = Math.max(0, Math.min(anchorInPhoto, Math.max(0, newHeadH - 1)));
rebuildItems(state, innerWidth, viewportHeight);
}
/** 移除指定照片(隐藏/删除事件):重定位到下一有效项,保锚不跳屏(§6.3)。
* 锚点在旧队列上解析(anchorRebuild 内部先找锚后换队列)。 */
export function removePhoto(
state: StreamEngineState,
photoId: string,
innerWidth: number,
viewportHeight: number,
): void {
const idx = state.queue.indexOf(photoId);
if (idx < 0) return;
const queue = state.queue.filter((_, i) => i !== idx);
const sizes = state.sizes.filter((_, i) => i !== idx);
const nextPhotos = queue.map((id, i) => ({
photoId: id,
width: sizes[i].width,
height: sizes[i].height,
}));
anchorRebuild(state, nextPhotos, innerWidth, viewportHeight);
}
@@ -0,0 +1,232 @@
// apps/viewer/src/embed/useVerticalPhotoStream.ts —— v2 垂直照片流滚动引擎
// 编排(UI-04 §3)。纯状态机在 stream.ts(advanceStream/anchorRebuild/
// removePhoto,vitest 直接覆盖);本组合式只做三件事:
// ① rAF 积分推进:dt 上限 50ms(长停顿不补跑),transform 逐帧直写 DOM
// (不经 Vue 响应式——§3.1「不在每帧触发整个树更新」);
// ② 退役/补尾 → items 低频同步 Vue 渲染列表(key 含循环代次);
// ③ 数据/几何变更锚定:photos 变化经 anchorRebuild 保屏幕位置(§6.2),
// 容器尺寸变化重测内宽并按锚重算(§6.2 末段)。
// 暂停语义(§4.2 完整原因集在 EmbedStream 汇聚为本处 running 闸门):
// running=false 停 rAF 但保留状态;恢复重置 lastTime(不瞬时补跑隐藏时间)。
// 单照片静态展示、零照片空态不启动 rAF(§3.2.8/9)。
import { onBeforeUnmount, onMounted, ref, shallowRef, watch, type Ref } from 'vue';
import type { PublicEmbedPhoto } from '../api/client';
import {
advanceStream,
anchorRebuild,
createStreamState,
photoDisplayHeight,
PHOTO_GAP_PX,
removePhoto,
type StreamEngineState,
type StreamRenderItem,
} from './stream';
export interface UseVerticalPhotoStreamOptions {
/** feed 照片序列(最新在前;合并/重置由 useEmbedFeed 保证)。 */
photos: Ref<PublicEmbedPhoto[]>;
/** 视口元素(.stream-viewport:clientWidth/clientHeight 测量基准)。 */
viewportEl: Ref<HTMLElement | null>;
/** 轨道元素(.stream-track:逐帧 transform 直写目标)。 */
trackEl: Ref<HTMLElement | null>;
/** 上滚速度(CSS px/s;配置变化即时生效)。 */
speedPxPerSec: Ref<number>;
/** 运行闸门:全部暂停原因清空才 true(§4.2 由组件层汇聚)。 */
running: Ref<boolean>;
/** 照片被移除回调(清理待点击目标/气泡归属,E04/E05 接线)。 */
onPhotoRemoved?: (photoId: string) => void;
}
export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
const { photos, viewportEl, trackEl, speedPxPerSec, running } = options;
/** 渲染列表(浅响应:退役/锚定时整体替换)。 */
const items = shallowRef<StreamRenderItem[]>([]);
/** 照片元数据索引(photoId → photo,渲染层取 URL/比例)。 */
const photoById = shallowRef(new Map<string, PublicEmbedPhoto>());
/** 当前滚动偏移(px;测试/联动只读视图,DOM 写入不经它)。 */
const offsetView = ref(0);
const state = shallowRef<StreamEngineState | null>(null);
let innerWidth = 193; // 195 - 2×1px 边框(挂载后实测覆盖)
let viewportHeight = 278; // 280 - 2
let rafId: number | null = null;
let lastTime: number | null = null;
let resizeObserver: ResizeObserver | null = null;
function measure(): void {
const el = viewportEl.value;
if (el === null) return;
const w = Math.round(el.clientWidth);
const h = Math.round(el.clientHeight);
if (w > 0) innerWidth = w;
if (h > 0) viewportHeight = h;
}
/** 渲染侧同步:items + 元数据索引 + transform(一次性)。transform 取整到
* 0.01px:浮点累计噪声不进 DOM(测量口径 ±2px,远宽于此精度)。 */
function writeTransform(): void {
const s = state.value;
const track = trackEl.value;
if (s === null || track === null) return;
track.style.transform = `translateY(${-(Math.round(s.offset * 100) / 100)}px)`;
}
/** 渲染侧同步:items + 元数据索引 + transform(一次性)。 */
function syncRender(): void {
const s = state.value;
if (s === null) return;
items.value = s.items.map((item) => ({ ...item }));
photoById.value = new Map(photos.value.map((p) => [p.photoId, p]));
offsetView.value = s.offset;
writeTransform();
}
/** 单张/零张:静态展示(不 rAF);两张以上才有滚动语义。 */
function scrollable(): boolean {
return state.value !== null && state.value.queue.length > 1;
}
function stopLoop(): void {
if (rafId !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(rafId);
}
rafId = null;
lastTime = null;
}
function frame(now: number): void {
rafId = null;
const s = state.value;
if (s === null || !scrollable()) return;
if (lastTime === null) {
lastTime = now;
} else {
const dt = now - lastTime;
lastTime = now;
if (dt > 0) {
const result = advanceStream(s, dt, speedPxPerSec.value, innerWidth, viewportHeight);
if (result.retired) {
items.value = s.items.map((item) => ({ ...item }));
}
offsetView.value = s.offset;
writeTransform();
}
}
if (running.value) {
rafId = requestAnimationFrame(frame);
}
}
function startLoop(): void {
if (rafId !== null || !scrollable() || !running.value) return;
lastTime = null; // 恢复重置计时(不补跑隐藏时间,§3.1)
rafId = requestAnimationFrame(frame);
}
/** photos/几何变更统一走锚定重建(保屏幕位置,§6.2)。 */
function reanchor(next: PublicEmbedPhoto[]): void {
measure();
if (state.value === null) {
if (next.length > 0) {
state.value = createStreamState({
photos: next.map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })),
innerWidth,
viewportHeight,
});
syncRender();
startLoop();
}
return;
}
anchorRebuild(
state.value,
next.map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })),
innerWidth,
viewportHeight,
);
syncRender();
// 数据变化后:可滚 → 续跑;不可滚(≤1 张)→ 停。
if (!scrollable()) stopLoop();
else startLoop();
}
/** 单张失败受控跳过(§3.3:错误边界移出队列;feed 轮询可再补回)。 */
function dropPhoto(photoId: string): void {
const s = state.value;
if (s === null || !s.queue.includes(photoId)) return;
removePhoto(s, photoId, innerWidth, viewportHeight);
options.onPhotoRemoved?.(photoId);
syncRender();
if (!scrollable()) stopLoop();
}
/**
* 当前与视口相交的照片(可见定义 §5.2:交集面积>0 且高度≥20px)——
* E05 气泡归属依据。纯计算,不触 DOM。
*/
function visiblePhotoIds(minVisiblePx = 20): string[] {
const s = state.value;
if (s === null || s.queue.length === 0) return [];
const result: string[] = [];
let top = -s.offset;
for (const item of s.items) {
const h = photoDisplayHeight(s.sizes[item.queueIdx], innerWidth);
const visibleTop = Math.max(0, top);
const visibleBottom = Math.min(viewportHeight, top + h);
if (visibleBottom - visibleTop >= minVisiblePx) {
result.push(item.photoId);
}
top += h + PHOTO_GAP_PX;
if (top >= viewportHeight) break;
}
return result;
}
watch(photos, (next) => reanchor(next), { immediate: false });
watch(running, (value) => {
if (value) startLoop();
else stopLoop();
});
watch(speedPxPerSec, () => {
/* 速度逐帧读取,即时生效;无需重排 */
});
onMounted(() => {
measure();
if (typeof ResizeObserver !== 'undefined' && viewportEl.value !== null) {
resizeObserver = new ResizeObserver(() => {
reanchor(photos.value);
});
resizeObserver.observe(viewportEl.value);
}
if (photos.value.length > 0) {
reanchor(photos.value);
}
});
onBeforeUnmount(() => {
stopLoop();
resizeObserver?.disconnect();
});
return {
items,
photoById,
offsetView,
dropPhoto,
visiblePhotoIds,
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
stepForTest(dtMs: number): void {
const s = state.value;
if (s === null || !scrollable()) return;
const result = advanceStream(s, dtMs, speedPxPerSec.value, innerWidth, viewportHeight);
if (result.retired) {
items.value = s.items.map((item) => ({ ...item }));
}
offsetView.value = s.offset;
writeTransform();
},
};
}