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:
浅念
2026-09-22 17:38:02 +08:00
parent f6ff1d44f2
commit 91c85e196a
15 changed files with 667 additions and 120 deletions
+42
View File
@@ -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();
}