diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts index 241cb26..c0f95e3 100644 --- a/apps/viewer/e2e/embed-v2.spec.ts +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -270,6 +270,136 @@ test.describe.serial('E03 recording: 30s+ including loop boundary', () => { }); }); +test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', () => { + async function trackTransform(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; + } + + test('fine-pointer hover pauses; leaving resumes after 500ms (§4.2)', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + await page.waitForTimeout(1500); + // 悬停 iframe 中心(细指针环境):300ms 后 transform 冻结。 + const box = await frame.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); + await page.waitForTimeout(400); + const pausedAt = await trackTransform(page); + await page.waitForTimeout(800); + expect(await trackTransform(page)).toBe(pausedAt); // 悬停期间静止 + // 离开:500ms 恢复延时后继续上滚。 + await page.mouse.move(40, 40); + await page.waitForTimeout(400); + const resumedAt = await trackTransform(page); + await page.waitForTimeout(900); + const after = await trackTransform(page); + expect(after).toBeLessThan(resumedAt); // 恢复滚动(translateY 更负) + }); + + test('photo click opens viewer deep link for the clicked photo (§4.1)', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); + // 先悬停冻结(避免点击瞬间目标位移——点击锁定的正是防错图语义)。 + const box = await frame.boundingBox(); + await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); + await page.waitForTimeout(400); + const target = inside.locator('a.stream-photo').first(); + const photoId = await target.getAttribute('data-photo-id'); + expect(photoId).toBeTruthy(); + const popupPromise = page.waitForEvent('popup', { timeout: 5000 }); + await target.click(); + const popup = await popupPromise; + expect(popup.url()).toContain(`/p/${photoId}`); + await popup.close(); + }); + + test('drag beyond 8px cancels click (§4.1)', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + const box = await frame.boundingBox(); + expect(box).not.toBeNull(); + const cx = (box!.x ?? 0) + 90; + const cy = (box!.y ?? 0) + 200; + let popupOpened = false; + page.on('popup', () => { + popupOpened = true; + }); + await page.mouse.move(cx, cy); + await page.waitForTimeout(300); + await page.mouse.down(); + await page.mouse.move(cx, cy - 30, { steps: 5 }); // 拖动 30px > 8px + await page.mouse.up(); + await page.waitForTimeout(800); + expect(popupOpened).toBe(false); + }); + + test('keyboard: Space toggles pause (no auto-resume), Escape collapses (§4.2)', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); + await page.waitForTimeout(1200); + // 聚焦流区域(点击视口非链接处不触发照片点击——顶部渐隐带命中视口)。 + const box = await frame.boundingBox(); + await page.mouse.click((box!.x ?? 0) + 90, (box!.y ?? 0) + 6); + await inside.locator('.stream-viewport').focus(); + await page.keyboard.press(' '); + await page.waitForTimeout(300); + const pausedAt = await trackTransform(page); + await page.waitForTimeout(1000); + expect(await trackTransform(page)).toBe(pausedAt); // Space 暂停:不自动恢复 + // Escape → collapse 上行宿主:wrapper 隐藏 + 把手出现。 + await page.keyboard.press('Escape'); + await expect(page.locator('[role="button"]').first()).toBeVisible(); + await expect(frame).toBeHidden(); + }); + + test('slot disable → destroy: wrapper and handle removed, no ghost (§6.3)', async ({ page }) => { + // 独立槽(不破坏主 slot 的后续用例)。 + const album = await seedAlbum({ + alias: 'ui04v2-destroy', + defaultLocale: 'zh-CN', + translations: [{ locale: 'zh-CN', title: '销毁测试' }], + }); + await seedPhoto(album.id, 'ui04v2-destroy-p1', { publishedAt: new Date() }); + await flushSync(album.id); + const slot = await createEmbedSlot('ui04-v2-destroy'); + await updateEmbedSlot(slot.id, { + albumId: album.id, + settings: { + position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, + imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, + displayMode: 'vertical-stream', scrollSpeed: 16, reactionsEnabled: true, + }, + }); + await flushOutbox(); + + currentHtml = ` + destroy host + + `; + await page.setViewportSize({ width: 800, height: 600 }); + await page.goto(`http://127.0.0.1:${HOST_PORT}`); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + // 停用:管理面 enabled=false → 高优先级 slot.updated → ov 404 → destroy。 + await updateEmbedSlot(slot.id, { enabled: false }); + await flushOutbox(); + // dev 构建 data-poll=1000 → 1s 轮询加速(loader 仅显式透传)。 + await expect(frame).toBeHidden({ timeout: 15_000 }); + await expect(page.locator('[role="button"]')).toHaveCount(0); + }); +}); + 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/embed/useVerticalPhotoStream.ts b/apps/viewer/src/embed/useVerticalPhotoStream.ts index 5e48cfa..c4d7555 100644 --- a/apps/viewer/src/embed/useVerticalPhotoStream.ts +++ b/apps/viewer/src/embed/useVerticalPhotoStream.ts @@ -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)。 */ diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 51e86ec..397935b 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -36,6 +36,8 @@ export default { // UI-04 v2 vertical photo stream window streamLabel: 'Event photo window', collapseWindow: 'Collapse photo window', + streamPaused: 'Photo scrolling paused', + streamResumed: 'Photo scrolling resumed', }, download: { action: 'Download photo', diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index e060484..49fd15e 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -37,6 +37,8 @@ export default { // —— UI-04 v2 垂直照片流窗 —— streamLabel: '活动照片窗', collapseWindow: '收起照片窗', + streamPaused: '照片滚动已暂停', + streamResumed: '照片滚动继续', }, download: { action: '下载图片', diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index 178939c..6549de3 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -1,16 +1,17 @@ + (EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2;交互层 §4: + · 暂停原因集合(Set,非互斥布尔):hiddenDocument/parentCollapsed(经 active)/ + offscreen/hoverFinePointer/focusWithin/pointerActive/manualScroll/ + reducedMotion/userPaused/insufficientContent——全部清空才自动滚动; + 瞬态原因(指针/焦点/滚轮/拖动)离开后 500ms 恢复;键盘 Space 暂停属 + 用户显式暂停,不自动恢复(§4.2); + · 点击语义(§4.1):pointerdown 锁定目标 photoId;位移 >8px 或目标已 + 下线 → 取消点击;photoUrl 失效(换绑)亦拦截; + · 手动查看(§4.3):滚轮/拖动/上下方向键直接平移有限窗口(引擎 + nudgeOffset,夹取窗口范围,不重放历史);Escape 收起;Space 暂停切换。 + 无 header/footer/页码/箭头/圆点(§2.2 硬性)。循环副本对辅助技术隐藏。 -->