feat(embed): ambient heart bubbles — always floating in the right band (user request 2026-09-23)

- ReactionLayer ambient prop: self-scheduled spawn every 400-800ms (jitter), band 56-232px avoiding the 44px action zone, stops on hidden page/unmount, shares MAX_ACTIVE=8 with real bursts
- EmbedStream mounts layer with ambient = reactions && !reducedMotion — persistent decoration independent of real likes (no burst frames, no count inflation; §5.2 anti-fake semantics unaffected)
- viewer 244 green

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 11:30:53 +08:00
parent df0c318599
commit d11ca16d1d
2 changed files with 70 additions and 10 deletions
+8 -2
View File
@@ -407,8 +407,14 @@ onBeforeUnmount(() => {
{{ t('embed.comingSoon') }}
</div>
<!-- E05 心形气泡层(装饰:pointer-events none + aria-hidden)。预览模式
恒挂载(模拟事件需要),生产按 reactions+albumToken。 -->
<ReactionLayer v-if="(reactions && albumToken !== null) || preview" ref="layerEl" />
恒挂载(模拟事件需要),生产按 reactions+albumToken。ambient 常驻
模式(用户决策 2026-09-23):右侧持续氛围气泡,不依赖真实点赞;
reducedMotion 关闭(§5.3 不生成动效)。 -->
<ReactionLayer
v-if="(reactions && albumToken !== null) || preview"
ref="layerEl"
:ambient="reactions && !reducedMotion"
/>
<!-- 聚焦小提示(效果示例 focus-tip:聚焦时显示键盘操作说明,不常驻)。 -->
<span class="focus-tip" aria-hidden="true">{{ t('embed.streamKeyHint') }}</span>
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
+62 -8
View File
@@ -1,17 +1,27 @@
<!-- apps/viewer/src/pages/embed/ReactionLayer.vue —— UI-04 E05 点赞心形粒子层。
效果参数逐项对齐 docs/designs/embed-preview-2026-09-22.html(效果示例冻结值,
并满足方案 §5.1 值域):
并满足方案 §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,
· 动画 heart-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)。 -->
· 事件批最多 3 颗、颗间 300ms 错峰;同屏上限 8;
pointer-events none + aria-hidden(§5.1)。
spawn(originTop, count) 由 EmbedStream 依据照片交集下侧定位调用(§5.2)。
ambient 常驻模式(用户决策 2026-09-23):不依赖真实点赞事件,右侧气泡带
持续有心上浮(纯装饰——不产生 likes.burst、不碰计数,与方案 §5.2「不
伪造他人点赞」的计数语义不冲突);页面隐藏时暂停生成,恢复后继续。 -->
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/** 常驻氛围模式:右侧持续上浮(真实点赞仍经 spawn 叠加,共享上限 8)。 */
ambient?: boolean;
}>(),
{ ambient: false },
);
const rootEl = ref<HTMLElement | null>(null);
@@ -20,11 +30,19 @@ const COLORS = ['#FF6B7A', '#FF8B98'] as const;
const DRIFTS = ['-8px', '5px', '8px'] as const;
const MAX_ACTIVE = 8;
const STAGGER_MS = 300;
/** 常驻节奏:400–800ms 随机间隔一颗(1.6s 生命周期下稳态 3–5 颗在屏,
* 与事件心共享同屏上限 8——事件到来时仍有余量)。 */
const AMBIENT_MIN_MS = 400;
const AMBIENT_MAX_MS = 800;
/** 常驻心纵向分布带(视口坐标):避开顶部 44px 操作区,下缘留 24px。 */
const AMBIENT_TOP_MIN = 56;
const AMBIENT_TOP_MAX = 232;
/** 全局序号(尺寸/颜色/漂移按 n%3 轮转——与效果示例 burst() 一致)。 */
let particleSeq = 0;
const timers = new Set<ReturnType<typeof setTimeout>>();
let alive = true;
let ambientTimer: ReturnType<typeof setTimeout> | null = null;
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>';
@@ -46,6 +64,41 @@ function spawnOne(originTop: number): void {
root.appendChild(el);
}
function spawnAmbientOne(): void {
const top = AMBIENT_TOP_MIN + Math.random() * (AMBIENT_TOP_MAX - AMBIENT_TOP_MIN);
spawnOne(top);
}
function scheduleAmbient(): void {
if (ambientTimer !== null || !alive || !props.ambient) return;
const delay = AMBIENT_MIN_MS + Math.random() * (AMBIENT_MAX_MS - AMBIENT_MIN_MS);
ambientTimer = setTimeout(() => {
ambientTimer = null;
// 页面隐藏暂停生成(在屏动画本就不可见;恢复可见自动续上)。
if (!document.hidden) spawnAmbientOne();
scheduleAmbient();
}, delay);
}
function stopAmbient(): void {
if (ambientTimer !== null) {
clearTimeout(ambientTimer);
ambientTimer = null;
}
}
watch(
() => props.ambient,
(value) => {
if (value) scheduleAmbient();
else stopAmbient();
},
);
onMounted(() => {
if (props.ambient) scheduleAmbient();
});
defineExpose({
/** 在 originTop(视口坐标,照片交集下侧)放一颗心(0/1/2/3 颗由调用方
* 按 heartsForDelta 决定;错峰 300ms)。 */
@@ -59,7 +112,7 @@ defineExpose({
timers.add(timer);
}
},
/** 清空粒子(收起/隐藏/换绑/销毁,§5.3)。 */
/** 清空粒子(收起/隐藏/换绑/销毁,§5.3;常驻生成随 ambient 属性停启)。 */
clear(): void {
for (const timer of timers) clearTimeout(timer);
timers.clear();
@@ -71,6 +124,7 @@ onBeforeUnmount(() => {
alive = false;
for (const timer of timers) clearTimeout(timer);
timers.clear();
stopAmbient();
});
</script>