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:
@@ -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,视觉极简,不常驻)。 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user