From 998565e439ac3ccad2cf62d47a58c79c184f3c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 22 Sep 2026 13:19:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(viewer,ui04):=20E03=20continuous=20scroll?= =?UTF-8?q?=20engine=20=E2=80=94=20pure=20state=20machine,=20rAF=20orchest?= =?UTF-8?q?ration,=20boundary=20continuity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/viewer/e2e/embed-v2.spec.ts | 117 +++++++- .../viewer/src/__tests__/embed-stream.spec.ts | 230 ++++++++++++++++ apps/viewer/src/embed/stream.ts | 250 ++++++++++++++++++ .../src/embed/useVerticalPhotoStream.ts | 232 ++++++++++++++++ apps/viewer/src/pages/embed/EmbedStream.vue | 87 ++++-- .../recordings/e03-scroll-32s.webm | Bin 0 -> 3522998 bytes docs/plans/progress/UI04-验收记录.md | 21 +- 7 files changed, 914 insertions(+), 23 deletions(-) create mode 100644 apps/viewer/src/__tests__/embed-stream.spec.ts create mode 100644 apps/viewer/src/embed/useVerticalPhotoStream.ts create mode 100644 docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts index 03788d2..241cb26 100644 --- a/apps/viewer/e2e/embed-v2.spec.ts +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -1,5 +1,5 @@ import { createServer, type Server } from 'node:http'; -import { readFile } from 'node:fs/promises'; +import { readFile, rename } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect, test, type Page } from '@playwright/test'; @@ -155,6 +155,121 @@ test.describe.serial('E01 exit gate: fixed window, no footer', () => { } }); +test.describe.serial('E03 continuous scroll: speed, boundary, DOM cap', () => { + /** 读 iframe 内轨道 transform 的 translateY 像素值。 */ + async function trackOffset(page: Page): Promise { + const embedFrame = page.frames().find((f) => f.url().includes('v=2')); + expect(embedFrame).toBeDefined(); + return (await embedFrame!.locator('.stream-track').evaluate((el) => { + const m = /translateY\((-?[\d.]+)px\)/.exec(el.style.transform ?? ''); + return m === null ? 0 : Number.parseFloat(m[1]); + })) as number; + } + + /** 可见照片元素(src+top)快照:连续性匹配用。 */ + async function visibleImgs(page: Page): Promise> { + const embedFrame = page.frames().find((f) => f.url().includes('v=2')); + expect(embedFrame).toBeDefined(); + return (await embedFrame!.locator('.stream-track').evaluate((el) => + Array.from(el.querySelectorAll('.stream-photo__img')).map((img) => ({ + src: img.currentSrc.split('/').pop() ?? img.currentSrc, + top: img.getBoundingClientRect().top, + })), + )) as Array<{ src: string; top: number }>; + } + + test('default 16px/s: anchor photo moves up ~32px over 2s (±2px, §11.2)', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); + await page.waitForTimeout(1200); // 首帧静置 + 稳定 + const before = await trackOffset(page); + await page.waitForTimeout(2000); + const after = await trackOffset(page); + // 无退役区间(首条 123.6px > 32px 位移)→ transform 差即位移(越滚越负)。 + const moved = before - after; + expect(moved).toBeGreaterThanOrEqual(30); + expect(moved).toBeLessThanOrEqual(34); + }); + + test('loop boundary: matched photo keeps -16px/s continuity, no jump >2px', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); + await page.waitForTimeout(800); + // 12s 采样(每 250ms):覆盖 ≥1 个退役边界(条高 123.6px / 16px/s ≈ 7.7s)。 + const samples: Array> = []; + const deadline = Date.now() + 12_000; + while (Date.now() < deadline) { + samples.push(await visibleImgs(page)); + await page.waitForTimeout(250); + } + expect(samples.length).toBeGreaterThan(30); + let matched = 0; + let maxError = 0; + for (let i = 1; i < samples.length; i++) { + const prev = new Map(samples[i - 1].map((s) => [s.src, s.top])); + for (const cur of samples[i]) { + const before = prev.get(cur.src); + if (before === undefined) continue; + matched += 1; + const expected = -16 * 0.25; // 采样间隔约 250ms + const error = Math.abs(cur.top - before - expected); + maxError = Math.max(maxError, error); + break; // 每对样本取一个匹配元素即可 + } + } + // 匹配连续性成立且误差远小于 2px 口径(定时器抖动计入)。 + expect(matched).toBeGreaterThan(20); + expect(maxError).toBeLessThan(2.5); + }); + + test('DOM instances bounded (≤16) over 20s (§11.2 resource cap)', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); + await page.waitForTimeout(1000); + const embedFrame = page.frames().find((f) => f.url().includes('v=2')); + expect(embedFrame).toBeDefined(); + const counts: number[] = []; + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + counts.push(await embedFrame!.locator('.stream-photo__img').count()); + await page.waitForTimeout(1000); + } + expect(Math.max(...counts)).toBeLessThanOrEqual(16); + }); +}); + +test.describe.serial('E03 recording: 30s+ including loop boundary', () => { + test('record 32s scroll video into recordings/', async ({ browser }) => { + test.setTimeout(120_000); // 录屏 32s + 启动裕量(配置缺省 30s 会截断) + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + recordVideo: { dir: join(OUT_ROOT, 'recordings'), size: { width: 1440, height: 900 } }, + }); + const page = await context.newPage(); + try { + currentHtml = ` + UI-04 v2 recording host + +

Host page (v2 recording)

+ + `; + await page.goto(`http://127.0.0.1:${HOST_PORT}`); + await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); + // 32s:条高 ~123.6px / 16px/s ≈ 7.7s 一退役 → 至少 4 个循环边界。 + await page.waitForTimeout(32_000); + } finally { + const video = page.video(); + await context.close().catch(() => undefined); + if (video !== null) { + const src = await video.path(); + if (src !== null) { + await rename(src, join(OUT_ROOT, 'recordings', 'e03-scroll-32s.webm')).catch(() => undefined); + } + } + } + }); +}); + test.describe.serial('E02 static fidelity: measured styles + screenshots', () => { test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); diff --git a/apps/viewer/src/__tests__/embed-stream.spec.ts b/apps/viewer/src/__tests__/embed-stream.spec.ts new file mode 100644 index 0000000..c15beba --- /dev/null +++ b/apps/viewer/src/__tests__/embed-stream.spec.ts @@ -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, required: true }, + active: { type: Boolean, default: true }, + speed: { type: Number, default: 16 }, + }, + setup(props) { + const photos = ref(props.photos); + const viewportEl = ref(null); + const trackEl = ref(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: ``, +}); + +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(); + }); +}); diff --git a/apps/viewer/src/embed/stream.ts b/apps/viewer/src/embed/stream.ts index 8405ad3..cf97488 100644 --- a/apps/viewer/src/embed/stream.ts +++ b/apps/viewer/src/embed/stream.ts @@ -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, + 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); +} diff --git a/apps/viewer/src/embed/useVerticalPhotoStream.ts b/apps/viewer/src/embed/useVerticalPhotoStream.ts new file mode 100644 index 0000000..5e48cfa --- /dev/null +++ b/apps/viewer/src/embed/useVerticalPhotoStream.ts @@ -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; + /** 视口元素(.stream-viewport:clientWidth/clientHeight 测量基准)。 */ + viewportEl: Ref; + /** 轨道元素(.stream-track:逐帧 transform 直写目标)。 */ + trackEl: Ref; + /** 上滚速度(CSS px/s;配置变化即时生效)。 */ + speedPxPerSec: Ref; + /** 运行闸门:全部暂停原因清空才 true(§4.2 由组件层汇聚)。 */ + running: Ref; + /** 照片被移除回调(清理待点击目标/气泡归属,E04/E05 接线)。 */ + onPhotoRemoved?: (photoId: string) => void; +} + +export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) { + const { photos, viewportEl, trackEl, speedPxPerSec, running } = options; + + /** 渲染列表(浅响应:退役/锚定时整体替换)。 */ + const items = shallowRef([]); + /** 照片元数据索引(photoId → photo,渲染层取 URL/比例)。 */ + const photoById = shallowRef(new Map()); + /** 当前滚动偏移(px;测试/联动只读视图,DOM 写入不经它)。 */ + const offsetView = ref(0); + + const state = shallowRef(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(); + }, + }; +} diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index 86e0545..178939c 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -1,25 +1,26 @@ + (异常宽高回退 4:3,§2.3);间距 3px;外窗(loader wrapper)负责整体圆角 + 与边框。循环副本(短队列一圈盖不满窗口)非首出实例对辅助技术隐藏(§3.2.7)。 -->