feat(viewer,ui04): E05 realtime hearts + effect-demo alignment (initial offset, heart params, decode gate, bidirectional manual scroll)
- reactions.ts pure fns: delta->hearts sampling, burstId dedupe (TTL/capacity), pending-visible cache (5s/50), 250ms emission throttle with merge - useEmbedReactions: read-only RealtimeClient subscription on album.publicToken (no viewer-page side effects, A15), rebind teardown, 10s grace disconnect, no fake bubbles on WS failure - ReactionLayer: demo-frozen heart effect (12/16/20 sizes, two colors, 110px rise, 1.6s ease-out, 300ms stagger, max 8, drop-shadow) in unscoped block (created elements lack scoped hash) - demo alignment: initial offset 32px, decode+500ms startup gate, 1:1 drag, arrows +-40, focus-tip, bidirectional manual scroll (manualScroll), loader handle 100x44 exact styles, <=599px offset default 12 - tests: +8 reactions unit (244 viewer), e2e E05: real cross-context like -> heart on correct photo (EMB-A09), zero likes/visits submission (EMB-A15), 16s hearts recording Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ const FIXTURES: Array<[string, number, number]> = [
|
||||
];
|
||||
|
||||
let slotToken = '';
|
||||
let albumToken = '';
|
||||
|
||||
async function seedFeed(alias: string): Promise<string> {
|
||||
const album = await seedAlbum({
|
||||
@@ -47,6 +48,7 @@ async function seedFeed(alias: string): Promise<string> {
|
||||
{ locale: 'en', title: `${alias} Album` },
|
||||
],
|
||||
});
|
||||
albumToken = album.publicToken;
|
||||
for (let i = 0; i < FIXTURES.length; i++) {
|
||||
const [file, width, height] = FIXTURES[i];
|
||||
const bytes = await readFile(join(HERE, 'fixtures', file));
|
||||
@@ -102,6 +104,11 @@ async function openHost(page: Page, viewport: { width: number; height: number })
|
||||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||
}
|
||||
|
||||
// 文件级 serial:config fullyParallel 会把同文件用例分散到多 worker,各自
|
||||
// 执行 beforeAll(种子/夹具服务器/admin 幂等种子并发冲突)。整文件单 worker
|
||||
// 串行,beforeAll 恰好一次。
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
slotToken = await seedFeed('ui04v2');
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -400,6 +407,121 @@ test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', (
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E05 realtime hearts: real burst drives particles', () => {
|
||||
test('real like from another context spawns heart on the liked photo (EMB-A09)', 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(500);
|
||||
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
|
||||
expect(embedFrame).toBeDefined();
|
||||
const photoId = await embedFrame!.locator('a.stream-photo').first().getAttribute('data-photo-id');
|
||||
expect(photoId).toBeTruthy();
|
||||
// 真实公开路径点赞(ov 域):服务端广播 likes.burst → 订阅出泡。
|
||||
const like = await page.request.post(
|
||||
`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`,
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: { sessionId: `ui04-e2e-${Date.now()}`, batchId: `batch-${Date.now()}`, delta: 1 },
|
||||
},
|
||||
);
|
||||
expect(like.status()).toBeLessThan(300);
|
||||
// 心形在 1.6s 动画内出现于 iframe。
|
||||
await expect(inside.locator('.heart').first()).toBeVisible({ timeout: 5_000 });
|
||||
// 归属正确:心形 top 落在被赞照片交集附近(±80px 容差:起点为交集下侧-10)。
|
||||
const heartTop = await inside.locator('.heart').first().boundingBox();
|
||||
const photoRect = await embedFrame!
|
||||
.locator(`a.stream-photo[data-photo-id="${photoId}"]`)
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(heartTop).not.toBeNull();
|
||||
expect(photoRect).not.toBeNull();
|
||||
const frameOriginY = (await frame.boundingBox())!.y ?? 0;
|
||||
// 统一 frame 坐标:bbox 均为页面坐标,减 frame 顶。期望起点 = 照片交集
|
||||
// 下侧 -10(origin),心形自 origin 上浮 ≤110px(动画进行中的实测位置
|
||||
// 落在 [origin-130, origin+5] 区间)。
|
||||
const heartFrame = heartTop!.y - frameOriginY;
|
||||
const originFrame = photoRect!.y + photoRect!.height - 10 - frameOriginY;
|
||||
expect(heartFrame).toBeLessThanOrEqual(originFrame + 5);
|
||||
expect(heartFrame).toBeGreaterThanOrEqual(originFrame - 130);
|
||||
});
|
||||
|
||||
test('embed itself submits no likes or visits (EMB-A15)', async ({ page }) => {
|
||||
const interactive: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/likes') || url.includes('/visits')) {
|
||||
if (req.method() === 'POST') interactive.push(url);
|
||||
}
|
||||
});
|
||||
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();
|
||||
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
|
||||
await page.mouse.wheel(0, 40);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.waitForTimeout(2_500);
|
||||
expect(interactive).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E05 recording: scroll + real like hearts', () => {
|
||||
test('record 16s scroll with periodic real likes into recordings/', async ({ browser }) => {
|
||||
test.setTimeout(90_000);
|
||||
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 = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
|
||||
<title>UI-04 v2 hearts recording host</title></head>
|
||||
<body style="min-height: 1200px">
|
||||
<h1>Host page (v2 hearts recording)</h1>
|
||||
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
|
||||
</body></html>`;
|
||||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
await page.waitForTimeout(2_000);
|
||||
// 每 2.4s 真实点赞一个可见照片(效果示例 simulate 节奏)。
|
||||
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const photoId =
|
||||
(await embedFrame!.locator('a.stream-photo').nth(i % 2).getAttribute('data-photo-id')) ?? '';
|
||||
if (photoId !== '') {
|
||||
await page.request
|
||||
.post(`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: {
|
||||
sessionId: `ui04-rec-${Date.now()}`,
|
||||
batchId: `batch-rec-${Date.now()}-${i}`,
|
||||
delta: 1,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
await page.waitForTimeout(2_400);
|
||||
}
|
||||
} 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', 'e05-hearts-16s.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 });
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,145 @@
|
||||
// apps/viewer/src/__tests__/embed-reactions.spec.ts —— UI-04 E05 气泡纯函数
|
||||
// 与订阅编排测试(§5.2):视觉抽样(delta→颗数)、burstId 去重(TTL/容量)、
|
||||
// 不可见缓存(5s/50)、发射节流(250ms 合并)、useEmbedReactions 事件管道
|
||||
// (ingestForTest 注入:可见归属/去重/节流/pending 转可见)。
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BurstDeduper,
|
||||
EmissionThrottle,
|
||||
heartsForDelta,
|
||||
PendingVisibleCache,
|
||||
} from '../embed/reactions';
|
||||
import { useEmbedReactions } from '../embed/useEmbedReactions';
|
||||
import type { ServerMessage } from '@photo/contracts/realtime';
|
||||
|
||||
describe('heartsForDelta 视觉抽样(§5.2)', () => {
|
||||
it('1→1;2–5→2;更大→3;非法→0', () => {
|
||||
expect(heartsForDelta(1)).toBe(1);
|
||||
expect(heartsForDelta(2)).toBe(2);
|
||||
expect(heartsForDelta(5)).toBe(2);
|
||||
expect(heartsForDelta(6)).toBe(3);
|
||||
expect(heartsForDelta(100)).toBe(3);
|
||||
expect(heartsForDelta(0)).toBe(0);
|
||||
expect(heartsForDelta(-3)).toBe(0);
|
||||
expect(heartsForDelta(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BurstDeduper(§5.2 TTL+容量)', () => {
|
||||
it('首见接受、重复拒绝、TTL 过期后重见接受', () => {
|
||||
const d = new BurstDeduper(1_000);
|
||||
expect(d.accept('b1', 0)).toBe(true);
|
||||
expect(d.accept('b1', 100)).toBe(false);
|
||||
expect(d.accept('b1', 1_200)).toBe(true); // 超 TTL
|
||||
});
|
||||
|
||||
it('容量满时驱逐最旧', () => {
|
||||
const d = new BurstDeduper(60_000, 2);
|
||||
d.accept('a', 0);
|
||||
d.accept('b', 1);
|
||||
d.accept('c', 2); // 驱逐 a
|
||||
expect(d.accept('a', 3)).toBe(true); // a 已被驱逐 → 重见接受
|
||||
expect(d.accept('c', 3)).toBe(false); // c 仍在
|
||||
});
|
||||
});
|
||||
|
||||
describe('PendingVisibleCache(§5.2 5s/50)', () => {
|
||||
it('可见时取出;超时丢弃;同照片累加封顶 3', () => {
|
||||
const c = new PendingVisibleCache(1_000, 50);
|
||||
c.add('p1', 2, 0);
|
||||
c.add('p1', 2, 100); // 合并 4 → 封顶 3
|
||||
expect(c.take('p1', 200)).toBe(3);
|
||||
expect(c.take('p1', 300)).toBe(0); // 已取走
|
||||
c.add('p2', 1, 0);
|
||||
expect(c.take('p2', 2_000)).toBe(0); // 超 TTL 丢弃
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmissionThrottle(§5.2 250ms 合并)', () => {
|
||||
it('冷却内并入队列;窗口到点整批放行;同照片封顶 3', () => {
|
||||
const t = new EmissionThrottle(250);
|
||||
const first = t.offer('p1', 1, 0);
|
||||
expect(first).not.toBeNull(); // 首发立即放行
|
||||
expect(t.offer('p2', 2, 100)).toBeNull(); // 冷却内
|
||||
expect(t.offer('p2', 3, 150)).toBeNull(); // 合并 5 → 3
|
||||
const batch = t.offer('p3', 1, 300); // 窗口到点
|
||||
expect(batch).not.toBeNull();
|
||||
expect(batch!.get('p2')).toBe(3);
|
||||
expect(batch!.get('p3')).toBe(1);
|
||||
expect(batch!.has('p1')).toBe(false); // 首发已放行
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEmbedReactions 事件管道(jsdom 挂载 + ingestForTest 注入)──
|
||||
|
||||
const Harness = defineComponent({
|
||||
props: {
|
||||
visible: { type: Array<string>, required: true },
|
||||
albumToken: { type: String, default: 'tok123' },
|
||||
},
|
||||
setup(props) {
|
||||
const emitted: Array<Map<string, number>> = [];
|
||||
const reactions = useEmbedReactions({
|
||||
albumToken: ref(props.albumToken),
|
||||
enabled: ref(true),
|
||||
active: ref(true),
|
||||
isPhotoVisible: (id) => props.visible.includes(id),
|
||||
onEmit: (batch) => emitted.push(batch),
|
||||
});
|
||||
return { reactions, emitted };
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
function burst(photoId: string, delta: number, burstId: string): ServerMessage {
|
||||
return { type: 'likes.burst', photoId, albumId: 'a1', delta, total: 1, countVersion: 1, burstId, originBatchIds: [] };
|
||||
}
|
||||
|
||||
describe('useEmbedReactions 管道(§5.2 归属/去重/节流/pending)', () => {
|
||||
it('可见照片 burst → 立即出泡;重复 burstId 不加计;delta 抽样生效', async () => {
|
||||
const wrapper = mount(Harness, { props: { visible: ['p1'] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'b1'));
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
expect(wrapper.vm.emitted[0].get('p1')).toBe(1);
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 5, 'b1')); // 重复 burstId → 忽略
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 100, 'b2')); // delta 100 → 3
|
||||
expect(wrapper.vm.emitted).toHaveLength(2);
|
||||
expect(wrapper.vm.emitted[1].get('p1')).toBe(3);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('非法 delta 忽略;冷却内合并为一批', async () => {
|
||||
const wrapper = mount(Harness, { props: { visible: ['p1', 'p2'] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 0, 'bad')); // delta<1 → 忽略
|
||||
expect(wrapper.vm.emitted).toHaveLength(0);
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'a'));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p2', 1, 'b')); // 冷却内并入
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'c')); // 放行合并批
|
||||
expect(wrapper.vm.emitted).toHaveLength(2);
|
||||
expect(wrapper.vm.emitted[1].get('p2')).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('不可见照片缓存 → 转可见后 500ms 巡检出泡(5s 内)', async () => {
|
||||
const visibleRef = ref<string[]>([]);
|
||||
const wrapper = mount(Harness, { props: { visible: [] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.reactions.ingestForTest(burst('hidden-p', 1, 'h1'));
|
||||
expect(wrapper.vm.emitted).toHaveLength(0);
|
||||
// 转可见(真实定时器:sweep 间隔 500ms)。
|
||||
await wrapper.setProps({ visible: ['hidden-p'] });
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
expect(wrapper.vm.emitted[0].get('hidden-p')).toBe(1);
|
||||
void visibleRef;
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -83,9 +83,9 @@ describe('advanceStream 匀速推进(§3.1)', () => {
|
||||
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 条
|
||||
advanceStreamFor(s, 8000, 16, W, H); // 初 32+128=160 > 123.6 → 退役 1 条
|
||||
expect(s.headIdx).toBe(1);
|
||||
expect(s.offset).toBeCloseTo(128 - entryH, 5);
|
||||
expect(s.offset).toBeCloseTo(32 + 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');
|
||||
@@ -95,8 +95,9 @@ describe('advanceStream 匀速推进(§3.1)', () => {
|
||||
|
||||
it('dt 上限 50ms:长停顿不补跑', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
const initial = s.offset; // 32(效果示例初始偏移)
|
||||
advanceStream(s, 60_000, 16, W, H);
|
||||
expect(s.offset).toBeCloseTo(16 * 0.05, 5);
|
||||
expect(s.offset).toBeCloseTo(initial + 16 * 0.05, 5);
|
||||
});
|
||||
|
||||
it('极矮条目一帧跨多条:循环退役至 offset 落回头条内', () => {
|
||||
@@ -199,11 +200,12 @@ describe('useVerticalPhotoStream 装配', () => {
|
||||
};
|
||||
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);
|
||||
// 初始偏移 32(效果示例 offset=32:顶部露首照上缘片段;多张才有)。
|
||||
expect(track.style.transform).toBe('translateY(-32px)');
|
||||
stepFor(1000); // 16px(stepForTest 单帧 ≤50ms 语义,分块推进)
|
||||
expect(track.style.transform).toBe('translateY(-48px)');
|
||||
stepFor(1000);
|
||||
expect(track.style.transform).toBe('translateY(-64px)');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,9 @@ export interface CreateStreamStateInput {
|
||||
photos: Array<{ photoId: string } & StreamPhotoSize>;
|
||||
innerWidth: number;
|
||||
viewportHeight: number;
|
||||
/** 初始图内偏移(px):效果示例取 32——窗口顶部露出首照片上缘片段,
|
||||
* 与设计图的「中途滚动」形态一致;缺省 32。 */
|
||||
initialOffset?: number;
|
||||
}
|
||||
|
||||
/** 由 feed 照片序列创建引擎状态(截尾 MAX_QUEUE;head=最新)。 */
|
||||
@@ -183,6 +186,14 @@ export function createStreamState(input: CreateStreamStateInput): StreamEngineSt
|
||||
items: [],
|
||||
};
|
||||
rebuildItems(state, input.innerWidth, input.viewportHeight);
|
||||
// 单张静态完整展示(§3.2.8):不裁顶部;两张以上才以 32px 初始偏移呈现
|
||||
// 「中途滚动」形态(效果示例 offset=32)。
|
||||
if (queue.length > 1) {
|
||||
state.offset = Math.max(
|
||||
0,
|
||||
Math.min(input.initialOffset ?? 32, entryHeight(sizes[0], input.innerWidth) - 1),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -241,6 +252,37 @@ export function advanceStreamFor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动滚动(§4.3;效果示例 translate 的双向分支):正/负向平移。正向越过
|
||||
* 头条 → 退役补尾;负向越过头部上缘 → 队尾前插回绕(上滑回看刚滚过的
|
||||
* 照片,有限窗口语义不变)。窗口重建保持渲染列表与 headIdx 一致。
|
||||
*/
|
||||
export function manualScroll(
|
||||
state: StreamEngineState,
|
||||
deltaPx: number,
|
||||
innerWidth: number,
|
||||
viewportHeight: number,
|
||||
): void {
|
||||
const queueLen = state.queue.length;
|
||||
if (queueLen === 0) return;
|
||||
state.offset += deltaPx;
|
||||
let guard = 0;
|
||||
while (guard < MAX_DOM_INSTANCES && state.offset >= entryHeight(state.sizes[state.headIdx], innerWidth)) {
|
||||
state.offset -= entryHeight(state.sizes[state.headIdx], innerWidth);
|
||||
state.headIdx = (state.headIdx + 1) % queueLen;
|
||||
if (state.headIdx === 0) state.gen += 1;
|
||||
guard += 1;
|
||||
}
|
||||
guard = 0;
|
||||
while (state.offset < 0 && guard < MAX_DOM_INSTANCES) {
|
||||
state.headIdx = (state.headIdx - 1 + queueLen) % queueLen;
|
||||
if (state.headIdx === queueLen - 1) state.gen -= 1;
|
||||
state.offset += entryHeight(state.sizes[state.headIdx], innerWidth);
|
||||
guard += 1;
|
||||
}
|
||||
rebuildItems(state, innerWidth, viewportHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据更新锚定(§6.2):以「首个与视口相交的 photoId + 图内偏移」为锚,
|
||||
* 队列合并(幸存保序、新图入未显示区)后重建状态,保持锚照片屏幕位置不变
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
advanceStream,
|
||||
anchorRebuild,
|
||||
createStreamState,
|
||||
MAX_DOM_INSTANCES,
|
||||
manualScroll,
|
||||
photoDisplayHeight,
|
||||
PHOTO_GAP_PX,
|
||||
removePhoto,
|
||||
@@ -163,34 +163,13 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动滚动(§4.3):外部(滚轮/拖动/键盘)直接平移当前窗口内容。夹取在
|
||||
* 已渲染窗口范围内(有限真实列表语义——不重放全部历史);自动恢复时从
|
||||
* 当前 offset 锚点继续(不回顶)。paused 状态下亦可用(reducedMotion 手动
|
||||
* 查看路径)。超出窗口尾部时按需补窗(anchor 语义由下次 reanchor 收敛)。
|
||||
* 手动滚动(§4.3;效果示例双向 translate 语义):正/负向平移(滚轮/拖动/
|
||||
* 键盘),负向队尾前插回绕。纯逻辑在 stream.ts manualScroll。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
manualScroll(s, deltaPx, innerWidth, viewportHeight);
|
||||
syncRender();
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export default {
|
||||
collapseWindow: 'Collapse photo window',
|
||||
streamPaused: 'Photo scrolling paused',
|
||||
streamResumed: 'Photo scrolling resumed',
|
||||
streamKeyHint: 'Space pause · ↑↓ scroll · Esc collapse',
|
||||
},
|
||||
download: {
|
||||
action: 'Download photo',
|
||||
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
collapseWindow: '收起照片窗',
|
||||
streamPaused: '照片滚动已暂停',
|
||||
streamResumed: '照片滚动继续',
|
||||
streamKeyHint: '空格 暂停 · ↑↓ 滚动 · Esc 收起',
|
||||
},
|
||||
download: {
|
||||
action: '下载图片',
|
||||
|
||||
@@ -142,6 +142,7 @@ function openPhoto(photoId: string): void {
|
||||
<div v-if="isV2 && feed.status !== 'gone'" ref="rootEl" class="embed-root-v2">
|
||||
<EmbedStream
|
||||
:photos="feed.status === 'ready' ? feed.photos : []"
|
||||
:album-token="feed.album?.publicToken ?? null"
|
||||
:photo-url="streamPhotoUrl"
|
||||
:speed="streamSpeed"
|
||||
:reactions="streamReactions"
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
||||
(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 硬性)。循环副本对辅助技术隐藏。 -->
|
||||
(EmbedPage 于 v=2 查询时渲染)。结构与效果对齐方案 §2.2 与效果示例
|
||||
docs/designs/embed-preview-2026-09-22.html:
|
||||
· 照片带:原比例、宽满、3px 缝;初始偏移 32px(顶部露首照上缘片段);
|
||||
· 交互层 §4:暂停原因集合(hidden/offscreen/hover(mouse)/focus/pointer/
|
||||
manual/reduced/userPaused/decode-loading),瞬态 500ms 恢复,Space 显式
|
||||
暂停不自动恢复;点击锁定 + >8px 拖动取消(1:1 跟手);滚轮/拖动/方向键
|
||||
±40px 手动(双向回绕);Escape 收起;
|
||||
· 气泡层 §5:ReactionLayer(真实 likes.burst 驱动,WS 只读订阅经
|
||||
useEmbedReactions——不提交点赞不上报浏览 A15);
|
||||
· 启动闸 §3.1:首窗图片 decode + 500ms 静置后才自动滚动;
|
||||
· 无 header/footer/页码/箭头/圆点;循环副本对辅助技术隐藏。
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicEmbedPhoto } from '../../api/client';
|
||||
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
|
||||
import { useEmbedReactions } from '../../embed/useEmbedReactions';
|
||||
import { photoRatio } from '../../embed/stream';
|
||||
import ReactionLayer from './ReactionLayer.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
photos: PublicEmbedPhoto[];
|
||||
/** 当前公开相册 token(气泡订阅目标;未解析为 null)。 */
|
||||
albumToken: string | null;
|
||||
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
|
||||
photoUrl: (photoId: string) => string;
|
||||
/** 自动上滚速度(CSS px/s)。 */
|
||||
speed: number;
|
||||
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
|
||||
/** 点赞气泡开关(false 不订阅不出泡)。 */
|
||||
reactions: boolean;
|
||||
/** 组件活动闸门(收起/隐藏/不可见/gone → false,停滚动与轮询)。 */
|
||||
active: boolean;
|
||||
@@ -39,6 +44,7 @@ const { t } = useI18n();
|
||||
|
||||
const viewportEl = ref<HTMLElement | null>(null);
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
const layerEl = ref<InstanceType<typeof ReactionLayer> | null>(null);
|
||||
|
||||
// ── 暂停原因集合(§4.2:Set 管理,自动滚动 = 全空)──
|
||||
const pauseReasons = reactive(new Set<string>());
|
||||
@@ -80,7 +86,7 @@ const stream = useVerticalPhotoStream({
|
||||
running: runningRef,
|
||||
});
|
||||
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
|
||||
const { items, photoById, dropPhoto, nudgeOffset } = stream;
|
||||
const { items, photoById, dropPhoto, nudgeOffset, visibleRectOf } = stream;
|
||||
|
||||
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
|
||||
function metaOf(photoId: string): PublicEmbedPhoto | null {
|
||||
@@ -104,7 +110,65 @@ const firstKeyByPhoto = computed(() => {
|
||||
return first;
|
||||
});
|
||||
|
||||
// ── 指针交互(§4.1/§4.2)──
|
||||
// ── E05 气泡:真实 burst → 节流合并 → 照片交集下侧出泡(§5.2)──
|
||||
const albumTokenRef = computed(() => props.albumToken);
|
||||
const reactionsEnabledRef = computed(() => props.reactions && props.albumToken !== null);
|
||||
const activeRef = computed(() => props.active);
|
||||
|
||||
const reactions = useEmbedReactions({
|
||||
albumToken: albumTokenRef,
|
||||
enabled: reactionsEnabledRef,
|
||||
active: activeRef,
|
||||
isPhotoVisible: (photoId) => stream.visiblePhotoIds().includes(photoId),
|
||||
onEmit: (batch) => {
|
||||
if (props.reducedMotion) return; // §5.3:reducedMotion 不生成动效
|
||||
for (const [photoId, hearts] of batch) {
|
||||
const rect = visibleRectOf(photoId);
|
||||
if (rect === null) continue;
|
||||
// 起点取交集下侧(originTop 由层内再避让顶部 44px 操作区)。
|
||||
layerEl.value?.spawn(rect.top + rect.height - 10, hearts);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 收起/隐藏/换绑/减少动效 → 清粒子(§5.3)。
|
||||
watch(activeRef, (value) => {
|
||||
if (!value) layerEl.value?.clear();
|
||||
});
|
||||
watch(
|
||||
() => props.albumToken,
|
||||
() => layerEl.value?.clear(),
|
||||
);
|
||||
watch(
|
||||
() => props.reducedMotion,
|
||||
(value) => {
|
||||
if (value) layerEl.value?.clear();
|
||||
},
|
||||
);
|
||||
|
||||
// ── 启动闸(§3.1):首窗图片 decode + 500ms 静置——仅首载一次 ──
|
||||
let bootstrapped = false;
|
||||
setReason('decode', true);
|
||||
watch(
|
||||
items,
|
||||
(list) => {
|
||||
if (bootstrapped || list.length === 0) return;
|
||||
bootstrapped = true;
|
||||
void nextTick()
|
||||
.then(() => {
|
||||
const imgs = Array.from(
|
||||
trackEl.value?.querySelectorAll<HTMLImageElement>('img') ?? [],
|
||||
).slice(0, 3); // 首可见区与下一张(§3.3 预加载口径)
|
||||
return Promise.all(imgs.map((img) => img.decode().catch(() => undefined)));
|
||||
})
|
||||
.then(() => {
|
||||
window.setTimeout(() => setReason('decode', false), 500);
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ── 指针交互(§4.1/§4.2;效果示例 1:1 跟手)──
|
||||
const DRAG_CANCEL_PX = 8;
|
||||
let pointerDownY: number | null = null;
|
||||
let pointerMoved = 0;
|
||||
@@ -112,6 +176,7 @@ let pointerMoved = 0;
|
||||
let lockedTarget: string | null = null;
|
||||
/** 拖动抑制一次 click。 */
|
||||
let suppressClick = false;
|
||||
let hoverPointerIsMouse = false;
|
||||
|
||||
function scheduleResume(): void {
|
||||
if (resumeTimer !== null) clearTimeout(resumeTimer);
|
||||
@@ -128,7 +193,7 @@ function onPointerDown(event: PointerEvent): void {
|
||||
pointerMoved = 0;
|
||||
const anchor = (event.target as HTMLElement).closest<HTMLElement>('a.stream-photo');
|
||||
lockedTarget = anchor === null ? null : (anchor.dataset.photoId ?? null);
|
||||
setReason('pointerActive', true);
|
||||
setReason('pointer', true);
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
@@ -137,16 +202,16 @@ function onPointerMove(event: PointerEvent): void {
|
||||
pointerMoved = Math.max(pointerMoved, Math.abs(dy));
|
||||
if (pointerMoved > DRAG_CANCEL_PX) {
|
||||
suppressClick = true;
|
||||
setReason('manualScroll', true);
|
||||
// 拖动平移:手指上移(dy<0)→ 内容上移(offset 增),同向跟手。
|
||||
nudgeOffset(-dy * 0.5);
|
||||
setReason('manual', true);
|
||||
// 1:1 跟手(效果示例 manual(press.last - e.clientY)):手指上移内容上移。
|
||||
nudgeOffset(pointerDownY - event.clientY);
|
||||
pointerDownY = event.clientY;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(): void {
|
||||
pointerDownY = null;
|
||||
setReason('pointerActive', false);
|
||||
setReason('pointer', false);
|
||||
scheduleResume();
|
||||
}
|
||||
|
||||
@@ -167,7 +232,7 @@ function onPhotoRemoved(photoId: string): void {
|
||||
if (lockedTarget === photoId) lockedTarget = null;
|
||||
}
|
||||
|
||||
// ── 键盘语义(§4.2:聚焦时 Space 暂停切换 / Escape 收起 / 上下键手动)──
|
||||
// ── 键盘语义(§4.2:Space 暂停切换 / Escape 收起 / 上下键 ±40 手动)──
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// 不抢输入字段(防御:iframe 内无输入,但保持通用语义)。
|
||||
@@ -185,50 +250,70 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setReason('manualScroll', true);
|
||||
nudgeOffset(event.key === 'ArrowDown' ? 60 : -60);
|
||||
setReason('manual', true);
|
||||
nudgeOffset(event.key === 'ArrowDown' ? 40 : -40);
|
||||
scheduleResume();
|
||||
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
|
||||
setTimeout(() => setReason('manual', false), RESUME_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
if (props.photos.length <= 1) return;
|
||||
event.preventDefault();
|
||||
setReason('manualScroll', true);
|
||||
setReason('manual', true);
|
||||
nudgeOffset(event.deltaY);
|
||||
scheduleResume();
|
||||
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
|
||||
setTimeout(() => setReason('manual', false), RESUME_DELAY_MS);
|
||||
}
|
||||
|
||||
// ── 可见性/悬停/焦点(§4.2/§5.3)──
|
||||
let hoverMedia: MediaQueryList | null = null;
|
||||
// ── 可见性/悬停/焦点(§4.2/§5.3;效果示例口径)──
|
||||
let visibilityObserver: IntersectionObserver | null = null;
|
||||
let manualClearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
setReason('hiddenDocument', document.hidden);
|
||||
setReason('hidden', document.hidden);
|
||||
if (document.hidden) layerEl.value?.clear();
|
||||
}
|
||||
function onPointerEnter(): void {
|
||||
if (hoverMedia?.matches === true) setReason('hoverFinePointer', true);
|
||||
function onPointerEnter(event: PointerEvent): void {
|
||||
// 悬停仅鼠标(效果示例 e.pointerType==='mouse'——触摸不粘滞)。
|
||||
if (event.pointerType === 'mouse') {
|
||||
hoverPointerIsMouse = true;
|
||||
if (resumeTimer !== null) {
|
||||
clearTimeout(resumeTimer);
|
||||
resumeTimer = null;
|
||||
}
|
||||
setReason('hover', true);
|
||||
}
|
||||
}
|
||||
function onPointerLeave(): void {
|
||||
setReason('hoverFinePointer', false);
|
||||
scheduleResume();
|
||||
function onPointerLeave(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse' || !hoverPointerIsMouse) return;
|
||||
hoverPointerIsMouse = false;
|
||||
// 500ms 延时恢复(效果示例 resumeTimer 模式)。
|
||||
if (resumeTimer !== null) clearTimeout(resumeTimer);
|
||||
resumeTimer = setTimeout(() => {
|
||||
resumeTimer = null;
|
||||
setReason('hover', false);
|
||||
scheduleResume();
|
||||
}, RESUME_DELAY_MS);
|
||||
}
|
||||
function onFocusIn(): void {
|
||||
setReason('focusWithin', true);
|
||||
setReason('focus', true);
|
||||
}
|
||||
function onFocusOut(): void {
|
||||
setReason('focusWithin', false);
|
||||
scheduleResume();
|
||||
// 500ms 后复检焦点是否仍在组件内(效果示例 contains(activeElement) 模式,
|
||||
// 避免子元素间焦点转移的闪烁)。
|
||||
setTimeout(() => {
|
||||
if (viewportEl.value === null) return;
|
||||
if (!viewportEl.value.contains(document.activeElement)) {
|
||||
setReason('focus', false);
|
||||
scheduleResume();
|
||||
}
|
||||
}, RESUME_DELAY_MS);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
onVisibilityChange();
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
hoverMedia = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'undefined' && viewportEl.value !== null) {
|
||||
visibilityObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -242,9 +327,11 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
setReason('destroyed', true);
|
||||
layerEl.value?.clear();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
visibilityObserver?.disconnect();
|
||||
if (resumeTimer !== null) clearTimeout(resumeTimer);
|
||||
if (manualClearTimer !== null) clearTimeout(manualClearTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -252,6 +339,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
ref="viewportEl"
|
||||
class="stream-viewport"
|
||||
:class="{ 'reduced-motion': reducedMotion }"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
:aria-label="t('embed.streamLabel')"
|
||||
@@ -295,40 +383,39 @@ onBeforeUnmount(() => {
|
||||
<div v-if="photos.length === 0" class="stream-empty" role="note">
|
||||
{{ t('embed.comingSoon') }}
|
||||
</div>
|
||||
<!-- E05 心形气泡层(装饰:pointer-events none + aria-hidden)。 -->
|
||||
<ReactionLayer v-if="reactions && albumToken !== null" ref="layerEl" />
|
||||
<!-- 聚焦小提示(效果示例 focus-tip:聚焦时显示键盘操作说明,不常驻)。 -->
|
||||
<span class="focus-tip" aria-hidden="true">{{ t('embed.streamKeyHint') }}</span>
|
||||
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
|
||||
<span class="stream-status" aria-live="polite">{{ statusText }}</span>
|
||||
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
|
||||
<button
|
||||
type="button"
|
||||
class="stream-collapse"
|
||||
:aria-label="t('embed.collapseWindow')"
|
||||
@click="emit('collapse')"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M2 5.25h8v1.5H2z" />
|
||||
</svg>
|
||||
<span aria-hidden="true">−</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* iframe 内容铺满 loader wrapper 的 195×280 窗(圆角/边框/阴影由宿主负责);
|
||||
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。 */
|
||||
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。效果示例 .viewport。 */
|
||||
.stream-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #10161e;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.stream-viewport:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.7);
|
||||
outline-offset: -2px;
|
||||
outline: 3px solid #459aff;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口
|
||||
* 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */
|
||||
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12)。 */
|
||||
.stream-viewport::before,
|
||||
.stream-viewport::after {
|
||||
content: '';
|
||||
@@ -350,7 +437,7 @@ onBeforeUnmount(() => {
|
||||
.stream-track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 3px 照片缝(§2.2) */
|
||||
/* 3px 照片缝(§2.2;效果示例 .photo-link margin-bottom:3px) */
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
@@ -369,10 +456,11 @@ onBeforeUnmount(() => {
|
||||
background: #f6f8fb;
|
||||
object-fit: cover;
|
||||
}
|
||||
/* 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44 锚定右上
|
||||
* (透明扩展),不与照片链接重叠触发。 */
|
||||
/* 收起入口(§2.4;效果示例 .collapse):44×44 触区右上、可视圆 24×24
|
||||
* 内缩 6px(#ffffffd1 底、− 字形 19px #647084)。 */
|
||||
.stream-collapse {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 44px;
|
||||
@@ -381,32 +469,27 @@ onBeforeUnmount(() => {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #647084;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.stream-collapse::before {
|
||||
content: '';
|
||||
.stream-collapse span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 6px 6px 0 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffffd1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 19px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
.stream-collapse svg {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
color: #647084;
|
||||
}
|
||||
.stream-collapse:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: -2px;
|
||||
outline: 3px solid #459aff;
|
||||
outline-offset: -3px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.stream-empty {
|
||||
@@ -418,6 +501,24 @@ onBeforeUnmount(() => {
|
||||
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
/* 聚焦小提示(效果示例 .focus-tip):仅 :focus-visible 显示。 */
|
||||
.focus-tip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
color: #ffffff;
|
||||
background: #13263ede;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stream-viewport:focus-visible .focus-tip {
|
||||
display: block;
|
||||
}
|
||||
/* 状态播报:仅读屏消费,不出布局(§4.2「不常驻抢视觉」)。 */
|
||||
.stream-status {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<!-- apps/viewer/src/pages/embed/ReactionLayer.vue —— UI-04 E05 点赞心形粒子层。
|
||||
效果参数逐项对齐 docs/designs/embed-preview-2026-09-22.html(效果示例冻结值,
|
||||
并满足方案 §5.1 值域):
|
||||
· 形状:实心 SVG 心形(非聊天气泡/非 emoji 字体——跨平台一致);
|
||||
· 尺寸三档 12/16/20、双色 #FF6B7A/#FF8B98 交替、右缘 10/20/30;
|
||||
· 动画 float 1.6s ease-out forwards:opacity 0→0.85(15%)→0.75(65%)→0,
|
||||
上浮 110px(§5.1 值域 90–130)、漂移 var(--drift) ∈ {-8,5,8}px、
|
||||
旋转 -5°→6°、scale .8→1.05;drop-shadow(0 1px 3px #ee5c7033);
|
||||
· 一批最多 3 颗、颗间 300ms 错峰(≤250ms 频率上限的保守取值);
|
||||
· 同屏上限 8;pointer-events none + aria-hidden(§5.1)。
|
||||
spawn(originTop, count) 由 EmbedStream 依据照片交集下侧定位调用(§5.2
|
||||
「起点取相应可见照片交集的下侧且不压操作区」——origin 已避开顶部 44px)。 -->
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const SIZES = [12, 16, 20] as const;
|
||||
const COLORS = ['#FF6B7A', '#FF8B98'] as const;
|
||||
const DRIFTS = ['-8px', '5px', '8px'] as const;
|
||||
const MAX_ACTIVE = 8;
|
||||
const STAGGER_MS = 300;
|
||||
|
||||
/** 全局序号(尺寸/颜色/漂移按 n%3 轮转——与效果示例 burst() 一致)。 */
|
||||
let particleSeq = 0;
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
let alive = true;
|
||||
|
||||
const HEART_SVG =
|
||||
'<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 21s-9-5.5-9-12a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.5-9 12-9 12Z"/></svg>';
|
||||
|
||||
function spawnOne(originTop: number): void {
|
||||
const root = rootEl.value;
|
||||
if (root === null || !alive) return;
|
||||
if (root.childElementCount >= MAX_ACTIVE) return; // 同屏上限(§5.1)
|
||||
const n = particleSeq++;
|
||||
const el = document.createElement('span');
|
||||
el.className = 'heart';
|
||||
el.innerHTML = HEART_SVG;
|
||||
const size = SIZES[n % 3];
|
||||
el.style.cssText = `width:${size}px;height:${size}px;top:${Math.max(
|
||||
44,
|
||||
originTop - size,
|
||||
)}px;right:${10 + (n % 3) * 10}px;color:${COLORS[n % 2]};--drift:${DRIFTS[n % 3]}`;
|
||||
el.addEventListener('animationend', () => el.remove());
|
||||
root.appendChild(el);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 在 originTop(视口坐标,照片交集下侧)放一颗心(0/1/2/3 颗由调用方
|
||||
* 按 heartsForDelta 决定;错峰 300ms)。 */
|
||||
spawn(originTop: number, count: number): void {
|
||||
const bounded = Math.max(0, Math.min(count, 3));
|
||||
for (let i = 0; i < bounded; i++) {
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
spawnOne(originTop);
|
||||
}, i * STAGGER_MS);
|
||||
timers.add(timer);
|
||||
}
|
||||
},
|
||||
/** 清空粒子(收起/隐藏/换绑/销毁,§5.3)。 */
|
||||
clear(): void {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
timers.clear();
|
||||
if (rootEl.value !== null) rootEl.value.replaceChildren();
|
||||
},
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
alive = false;
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
timers.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="heart-layer" aria-hidden="true"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.heart-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 心形样式不可 scoped:粒子元素为运行时 createElement(无 data-v 哈希,
|
||||
scoped 选择器不命中)。以 .heart-layer 命名空间隔离(本页为嵌入 iframe
|
||||
专页,无 .heart 类冲突面)。效果值冻结自 embed-preview-2026-09-22.html。 -->
|
||||
<style>
|
||||
.heart-layer .heart {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
opacity: 0;
|
||||
animation: heart-float 1.6s ease-out forwards;
|
||||
filter: drop-shadow(0 1px 3px #ee5c7033);
|
||||
}
|
||||
@keyframes heart-float {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 0) rotate(-5deg) scale(0.8);
|
||||
}
|
||||
15% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
65% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--drift), -110px) rotate(6deg) scale(1.05);
|
||||
}
|
||||
}
|
||||
/* 减少动态效果:粒子不生成(生成侧拦截)+ 已在屏粒子立即隐藏。 */
|
||||
.reduced-motion .heart-layer .heart {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
Binary file not shown.
@@ -64,7 +64,7 @@
|
||||
- [x] E02 静态高保真样板(登记见下)
|
||||
- [x] E03 连续滚动(登记见下)
|
||||
- [x] E04 操作和生命周期(登记见下)
|
||||
- [ ] E05 实时气泡
|
||||
- [x] E05 实时气泡(登记见下;含效果示例 embed-preview-2026-09-22.html 对齐)
|
||||
- [ ] E06 管理预览与接入
|
||||
- [ ] E07 验收(EMB-A01…A16 逐项见第 5 节)
|
||||
|
||||
@@ -124,6 +124,36 @@
|
||||
- E04 出口确认:小窗可操作(暂停/手动/键盘/点击四通道),动画不造成点错照片
|
||||
(点击锁定+拖动取消断言)。
|
||||
|
||||
### 效果示例对齐登记(2026-09-22,用户指定 docs/designs/embed-preview-2026-09-22.html)
|
||||
|
||||
以效果示例为准重调的实现值(方案值域内的具体冻结):
|
||||
- 初始偏移 32px(顶部露首照上缘片段,示例 offset=32;单张仍完整展示);
|
||||
- 心形:实心 SVG 心、尺寸 12/16/20 轮转、双色 #FF6B7A/#FF8B98 交替、右缘
|
||||
10/20/30、漂移 {-8,5,8}px、旋转 -5°→6°、scale .8→1.05、上浮 110px、
|
||||
1.6s ease-out(透明度 0/.85/.75/0)、drop-shadow——ReactionLayer 用非 scoped
|
||||
样式块(createElement 粒子无 data-v 哈希,scoped 选择器不命中——踩坑登记);
|
||||
一批 ≤3 颗、颗间 300ms 错峰、同屏 ≤8;
|
||||
- 拖动 1:1 跟手(去 0.5 系数);方向键 ±40;聚焦小提示 focus-tip;
|
||||
- 启动闸:首窗图片 decode + 500ms 静置(decode 原因位);
|
||||
- 手动滚动双向回绕(示例 translate 负向 prepend 分支——stream.ts manualScroll);
|
||||
- loader v2:把手 100×44/圆角 22/1px #e3eaf2 边/白底/蓝图标 16+12px 文案;
|
||||
默认水平偏移 ≤599px 视口 12px;收起钮 − 字形 19px(#ffffffd1 底)。
|
||||
|
||||
### E05 登记(2026-09-22)
|
||||
|
||||
- 架构:纯函数 reactions.ts(heartsForDelta 抽样 1→1/2–5→2/更大→3;BurstDeduper
|
||||
TTL30s/容量200;PendingVisibleCache 5s/50 条;EmissionThrottle 250ms 合并封顶3)
|
||||
+ 编排 useEmbedReactions.ts(复用 RealtimeClient 只读订阅 album.publicToken——
|
||||
不经 useAlbumRealtime 带出观众页副作用;换绑停连清态;失活 10s 宽限断连;
|
||||
WS 断线不伪造气泡)+ ReactionLayer.vue 粒子层(§5.1 值域 + 示例冻结值)。
|
||||
- 归属(§5.2):burst → 照片可见(交集 ≥20px)→ visibleRectOf 交集下侧-10 出泡;
|
||||
不可见 → 5s 缓存转可见补放;收起/隐藏/换绑/reducedMotion 清粒子。
|
||||
- 单测:embed-reactions.spec.ts 8 用例——全仓 viewer 244 绿。
|
||||
- e2e(E05 块 2 用例 + 录屏):真实公开路径点赞(另一上下文 POST likes)→
|
||||
心形出现于被赞照片交集下侧区间(EMB-A09);组件自身零点赞/浏览提交
|
||||
(网络断言,EMB-A15);16s 真实点赞节奏录屏 recordings/e05-hearts-16s.webm。
|
||||
- E05 出口确认:两上下文真实点赞触发正确图气泡(归属区间断言通过)。
|
||||
|
||||
## 4 测量与证据索引
|
||||
|
||||
(E02+ 起登记:尺寸实测、位移曲线、DOM/连接上限统计、录屏清单。)
|
||||
|
||||
@@ -95,8 +95,8 @@ import {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收起把手(§2.4):白底胶囊、蓝色相机图标 + 照片/Photos 文案;全部
|
||||
// inline style(零宿主 CSS 污染);有效点击区 ≥44×44。
|
||||
// 收起把手(§2.4;效果示例 .handle 精确值):100×44 白底胶囊、圆角 22、
|
||||
// 1px #e3eaf2 边、蓝图标 16 + 12px 文案;全部 inline style(零宿主污染)。
|
||||
function createHandle(inst) {
|
||||
var h = document.createElement('div');
|
||||
h.setAttribute('role', 'button');
|
||||
@@ -105,24 +105,24 @@ import {
|
||||
var s = h.style;
|
||||
s.position = 'fixed';
|
||||
s.height = HANDLE_MIN + 'px';
|
||||
s.minWidth = '88px';
|
||||
s.width = '100px';
|
||||
s.boxSizing = 'border-box';
|
||||
s.padding = '0 16px';
|
||||
s.borderRadius = '22px';
|
||||
s.border = '1px solid #e3eaf2';
|
||||
s.background = '#ffffff';
|
||||
s.boxShadow = '0 2px 10px rgba(15,23,42,0.18)';
|
||||
s.boxShadow = '0 4px 16px rgba(15,23,42,0.125)';
|
||||
s.zIndex = String(inst.config.zIndex);
|
||||
s.cursor = 'pointer';
|
||||
s.display = 'flex';
|
||||
s.alignItems = 'center';
|
||||
s.justifyContent = 'center';
|
||||
s.gap = '6px';
|
||||
s.font = '500 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif';
|
||||
s.color = '#1A73E8';
|
||||
s.gap = '8px';
|
||||
s.font = '400 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif';
|
||||
s.color = '#32618e';
|
||||
s.whiteSpace = 'nowrap';
|
||||
h.innerHTML =
|
||||
'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">' +
|
||||
'<path fill="#1A73E8" d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>' +
|
||||
'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" style="color:#1a73e8">' +
|
||||
'<path fill="currentColor" d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>' +
|
||||
'<span>' + (isZh(inst.config.lang) ? '照片' : 'Photos') + '</span>';
|
||||
function activate(ev) {
|
||||
if (ev.type === 'keydown') {
|
||||
@@ -153,11 +153,12 @@ import {
|
||||
return inst.collapsed ? HANDLE_MIN : inst.height;
|
||||
}
|
||||
|
||||
/** 默认水平偏移:未配置时桌面 16 / 窄屏 12(§2.1)。data-* 显式或后台
|
||||
* 配置到达后(offsetConfigured)按配置值。 */
|
||||
/** 默认水平偏移:未配置时桌面 16 / ≤599px 视口 12(§2.1;效果示例
|
||||
* media(max-width:599px) 档)。data-* 显式或后台配置到达后
|
||||
* (offsetConfigured)按配置值。 */
|
||||
function effectiveOffsetX(inst) {
|
||||
if (inst.config.explicit.offsetX || inst.offsetConfigured) return inst.config.offsetX;
|
||||
return inst.narrow ? 12 : 16;
|
||||
return w.innerWidth <= 599 ? 12 : 16;
|
||||
}
|
||||
|
||||
/** wrapper 几何:W=min(195,可用宽)、H=min(280,可用高);不足操作下限时
|
||||
|
||||
Reference in New Issue
Block a user