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:
@@ -0,0 +1,230 @@
|
||||
// apps/viewer/src/__tests__/embed-stream.spec.ts —— UI-04 v2 滚动引擎测试。
|
||||
// 纯状态机矩阵(§3.1/§3.2/§6.2:匀速积分、退役边界、循环代次 key、锚定
|
||||
// 重建、移除跳过)+ 组合式装配(挂载建窗、photos 变化锚定、单张不滚、
|
||||
// stepForTest 受控推进、dropPhoto 清理)。
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
advanceStream,
|
||||
advanceStreamFor,
|
||||
anchorRebuild,
|
||||
createStreamState,
|
||||
photoRatio,
|
||||
removePhoto,
|
||||
MAX_DOM_INSTANCES,
|
||||
type StreamEngineState,
|
||||
} from '../embed/stream';
|
||||
import { useVerticalPhotoStream } from '../embed/useVerticalPhotoStream';
|
||||
import type { PublicEmbedPhoto } from '../api/client';
|
||||
|
||||
const W = 193; // 195 - 2×1 边框
|
||||
const H = 278; // 280 - 2
|
||||
|
||||
function photo(id: string, width: number, height: number): PublicEmbedPhoto & { photoId: string } {
|
||||
return {
|
||||
photoId: id,
|
||||
thumb: { url: `https://img.test/${id}.jpg`, expiresAt: '2099-01-01T00:00:00.000Z' },
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 全等高样本:每条高 = width 比例 × 193 + 3 缝(800×500 → 120.6+3)。 */
|
||||
function uniform(n: number, width = 800, height = 500): PublicEmbedPhoto[] {
|
||||
return Array.from({ length: n }, (_, i) => photo(`p${i}`, width, height));
|
||||
}
|
||||
|
||||
function stateOf(photos: PublicEmbedPhoto[]): StreamEngineState {
|
||||
return createStreamState({
|
||||
photos: photos.map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })),
|
||||
innerWidth: W,
|
||||
viewportHeight: H,
|
||||
});
|
||||
}
|
||||
|
||||
describe('photoRatio 防御(§2.3)', () => {
|
||||
it('正常比例;异常宽高/超长图回退与钳制', () => {
|
||||
expect(photoRatio({ width: 800, height: 500 })).toBeCloseTo(0.625);
|
||||
expect(photoRatio({ width: 0, height: 100 })).toBe(4 / 3);
|
||||
expect(photoRatio({ width: 100, height: NaN })).toBe(4 / 3);
|
||||
expect(photoRatio({ width: 100, height: 1e9 })).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStreamState 有限窗口(§3.2)', () => {
|
||||
it('窗口覆盖 viewport+480 缓冲;DOM 实例有上限', () => {
|
||||
const s = stateOf(uniform(30));
|
||||
// 每条 120.6+3=123.6 → 覆盖 278+480=758 需 ⌈758/123.6⌉=7 条。
|
||||
expect(s.items.length).toBe(7);
|
||||
expect(s.items.length).toBeLessThanOrEqual(MAX_DOM_INSTANCES);
|
||||
expect(s.items[0]).toMatchObject({ photoId: 'p0', queueIdx: 0, gen: 0 });
|
||||
});
|
||||
|
||||
it('短队列一圈盖不满 → 循环副本、key 含代次且唯一', () => {
|
||||
const s = stateOf(uniform(2));
|
||||
expect(s.items.length).toBeGreaterThanOrEqual(7);
|
||||
const keys = s.items.map((i) => i.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
// 第 3 条起为副本(gen=1)。
|
||||
expect(s.items[2].photoId).toBe('p0');
|
||||
expect(s.items[2].gen).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanceStream 匀速推进(§3.1)', () => {
|
||||
it('16px/s × 2000ms = 32px;跨界过程屏幕内容不变(offset 回扣)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
const before = s.offset;
|
||||
advanceStreamFor(s, 2000, 16, W, H);
|
||||
expect(s.offset).toBeCloseTo(before + 32, 5);
|
||||
});
|
||||
|
||||
it('头条目完整滚出 → 退役一次(headIdx+1,offset 减该条高+缝)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
const entryH = (500 / 800) * W + 3; // ≈123.6
|
||||
advanceStreamFor(s, 8000, 16, W, H); // 128px > 123.6 → 退役 1 条
|
||||
expect(s.headIdx).toBe(1);
|
||||
expect(s.offset).toBeCloseTo(128 - entryH, 5);
|
||||
// 头实例已轮换;窗口自 p1 起覆盖 758px:p1..p5(618)+p0#1(742)+p1#1(865≥758)。
|
||||
expect(s.items[0].photoId).toBe('p1');
|
||||
expect(s.items.at(-1)?.photoId).toBe('p1');
|
||||
expect(s.items.at(-1)?.gen).toBe(1);
|
||||
expect(s.items[5]).toMatchObject({ photoId: 'p0', gen: 1 });
|
||||
});
|
||||
|
||||
it('dt 上限 50ms:长停顿不补跑', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStream(s, 60_000, 16, W, H);
|
||||
expect(s.offset).toBeCloseTo(16 * 0.05, 5);
|
||||
});
|
||||
|
||||
it('极矮条目一帧跨多条:循环退役至 offset 落回头条内', () => {
|
||||
const s = stateOf(uniform(3, 1200, 400)); // 每条 64.3+3 ≈67.3
|
||||
advanceStream(s, 50, 300, W, H); // 一帧 15px? no: 300*0.05=15px —— 用 5000ms 帧长不可超50
|
||||
// 重新计算:50ms × 300px/s = 15px 不足一条。改用 32px/s × 50ms × 多帧模拟:
|
||||
let retired = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = advanceStream(s, 50, 320, W, H); // 每帧 16px
|
||||
retired = retired || r.retired;
|
||||
}
|
||||
expect(retired).toBe(true);
|
||||
expect(s.offset).toBeLessThan((400 / 1200) * W + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchorRebuild 数据更新锚定(§6.2)', () => {
|
||||
it('幸存照片屏幕位置不变:滚过 60px 后更新队列,锚照片图内偏移保留', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H); // offset ≈64
|
||||
const anchorBefore = { head: s.headIdx, offset: s.offset };
|
||||
// 服务端同序返回(新签名 URL 场景)
|
||||
anchorRebuild(
|
||||
s,
|
||||
uniform(6).map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
expect(s.headIdx).toBe(anchorBefore.head);
|
||||
expect(s.offset).toBeCloseTo(anchorBefore.offset, 5);
|
||||
});
|
||||
|
||||
it('新照片追加队尾:不入当前显示区(锚照片仍是 head)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H);
|
||||
const head = s.headIdx;
|
||||
const next = uniform(6);
|
||||
next.push(photo('p-new', 800, 500));
|
||||
anchorRebuild(s, next.map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })), W, H);
|
||||
expect(s.headIdx).toBe(head);
|
||||
expect(s.queue.at(-1)).toBe('p-new');
|
||||
expect(s.items[0].photoId).toBe(`p${head}`);
|
||||
});
|
||||
|
||||
it('锚照片被移除 → 回退最近幸存(不跳屏到未知态)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H); // head=p0(offset 64)
|
||||
// 移除 p0(当前 head 且为锚)
|
||||
removePhoto(s, 'p0', W, H);
|
||||
expect(s.queue).not.toContain('p0');
|
||||
expect(s.headIdx).toBe(0); // 回退后 head 为原次条
|
||||
expect(s.items.every((i) => i.photoId !== 'p0')).toBe(true);
|
||||
});
|
||||
|
||||
it('removePhoto 保留屏幕位置:未滚出的锚不受影响', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H);
|
||||
const before = { head: s.headIdx, offset: s.offset };
|
||||
removePhoto(s, 'p5', W, H); // p5 在窗口尾部,不在视口顶
|
||||
expect(s.headIdx).toBe(before.head);
|
||||
expect(s.offset).toBeCloseTo(before.offset, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 组合式装配(jsdom)──
|
||||
|
||||
const Harness = defineComponent({
|
||||
props: {
|
||||
photos: { type: Array<PublicEmbedPhoto>, required: true },
|
||||
active: { type: Boolean, default: true },
|
||||
speed: { type: Number, default: 16 },
|
||||
},
|
||||
setup(props) {
|
||||
const photos = ref(props.photos);
|
||||
const viewportEl = ref<HTMLElement | null>(null);
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
const stream = useVerticalPhotoStream({
|
||||
photos,
|
||||
viewportEl,
|
||||
trackEl,
|
||||
speedPxPerSec: ref(props.speed),
|
||||
running: ref(props.active && props.photos.length > 1),
|
||||
});
|
||||
return { viewportEl, trackEl, stream, photos };
|
||||
},
|
||||
template: `<div ref="viewportEl" style="width:193px;height:278px"><div ref="trackEl"><a v-for="i of stream.items.value" :key="i.key" :data-photo="i.photoId">{{ i.photoId }}</a></div></div>`,
|
||||
});
|
||||
|
||||
describe('useVerticalPhotoStream 装配', () => {
|
||||
it('挂载建窗;stepForTest 受控推进并写 transform', async () => {
|
||||
const wrapper = mount(Harness, { props: { photos: uniform(5) } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const stepFor = (ms: number): void => {
|
||||
let left = ms;
|
||||
while (left > 0) {
|
||||
const dt = Math.min(left, 50);
|
||||
wrapper.vm.stream.stepForTest(dt);
|
||||
left -= dt;
|
||||
}
|
||||
};
|
||||
expect(wrapper.vm.stream.items.value.length).toBeGreaterThan(0);
|
||||
const track = wrapper.vm.trackEl as HTMLElement;
|
||||
expect(track.style.transform).toBe('translateY(0px)');
|
||||
stepFor(1000); // 16px(stepForTest 单帧 ≤50ms 语义,分块推进)
|
||||
expect(track.style.transform).toBe('translateY(-16px)');
|
||||
stepFor(1000);
|
||||
expect(track.style.transform).toBe('translateY(-32px)');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('单张:静态展示不推进(§3.2.8)', async () => {
|
||||
const wrapper = mount(Harness, { props: { photos: uniform(1) } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
for (let i = 0; i < 100; i++) wrapper.vm.stream.stepForTest(50);
|
||||
expect(wrapper.vm.stream.offsetView.value).toBe(0);
|
||||
expect((wrapper.vm.stream.items.value)).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('照片加载失败 → dropPhoto 移出队列并同步渲染(§3.3)', async () => {
|
||||
const wrapper = mount(Harness, { props: { photos: uniform(4) } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.stream.dropPhoto('p0');
|
||||
expect(
|
||||
wrapper.vm.stream.items.value.every((i) => i.photoId !== 'p0'),
|
||||
).toBe(true);
|
||||
wrapper.vm.stream.dropPhoto('p1');
|
||||
expect(wrapper.vm.stream.items.value.every((i) => i.photoId !== 'p1')).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user