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') }}
|
{{ t('embed.comingSoon') }}
|
||||||
</div>
|
</div>
|
||||||
<!-- E05 心形气泡层(装饰:pointer-events none + aria-hidden)。预览模式
|
<!-- E05 心形气泡层(装饰:pointer-events none + aria-hidden)。预览模式
|
||||||
恒挂载(模拟事件需要),生产按 reactions+albumToken。 -->
|
恒挂载(模拟事件需要),生产按 reactions+albumToken。ambient 常驻
|
||||||
<ReactionLayer v-if="(reactions && albumToken !== null) || preview" ref="layerEl" />
|
模式(用户决策 2026-09-23):右侧持续氛围气泡,不依赖真实点赞;
|
||||||
|
reducedMotion 关闭(§5.3 不生成动效)。 -->
|
||||||
|
<ReactionLayer
|
||||||
|
v-if="(reactions && albumToken !== null) || preview"
|
||||||
|
ref="layerEl"
|
||||||
|
:ambient="reactions && !reducedMotion"
|
||||||
|
/>
|
||||||
<!-- 聚焦小提示(效果示例 focus-tip:聚焦时显示键盘操作说明,不常驻)。 -->
|
<!-- 聚焦小提示(效果示例 focus-tip:聚焦时显示键盘操作说明,不常驻)。 -->
|
||||||
<span class="focus-tip" aria-hidden="true">{{ t('embed.streamKeyHint') }}</span>
|
<span class="focus-tip" aria-hidden="true">{{ t('embed.streamKeyHint') }}</span>
|
||||||
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
|
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
<!-- apps/viewer/src/pages/embed/ReactionLayer.vue —— UI-04 E05 点赞心形粒子层。
|
<!-- apps/viewer/src/pages/embed/ReactionLayer.vue —— UI-04 E05 点赞心形粒子层。
|
||||||
效果参数逐项对齐 docs/designs/embed-preview-2026-09-22.html(效果示例冻结值,
|
效果参数逐项对齐 docs/designs/embed-preview-2026-09-22.html(效果示例冻结值,
|
||||||
并满足方案 §5.1 值域):
|
并满足方案 §5.1 的值域):
|
||||||
· 形状:实心 SVG 心形(非聊天气泡/非 emoji 字体——跨平台一致);
|
· 形状:实心 SVG 心形(非聊天气泡/非 emoji 字体——跨平台一致);
|
||||||
· 尺寸三档 12/16/20、双色 #FF6B7A/#FF8B98 交替、右缘 10/20/30;
|
· 尺寸三档 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、
|
上浮 110px(§5.1 值域 90–130)、漂移 var(--drift) ∈ {-8,5,8}px、
|
||||||
旋转 -5°→6°、scale .8→1.05;drop-shadow(0 1px 3px #ee5c7033);
|
旋转 -5°→6°、scale .8→1.05;drop-shadow(0 1px 3px #ee5c7033);
|
||||||
· 一批最多 3 颗、颗间 300ms 错峰(≤250ms 频率上限的保守取值);
|
· 事件批最多 3 颗、颗间 300ms 错峰;同屏上限 8;
|
||||||
· 同屏上限 8;pointer-events none + aria-hidden(§5.1)。
|
pointer-events none + aria-hidden(§5.1)。
|
||||||
spawn(originTop, count) 由 EmbedStream 依据照片交集下侧定位调用(§5.2
|
spawn(originTop, count) 由 EmbedStream 依据照片交集下侧定位调用(§5.2)。
|
||||||
「起点取相应可见照片交集的下侧且不压操作区」——origin 已避开顶部 44px)。 -->
|
ambient 常驻模式(用户决策 2026-09-23):不依赖真实点赞事件,右侧气泡带
|
||||||
|
持续有心上浮(纯装饰——不产生 likes.burst、不碰计数,与方案 §5.2「不
|
||||||
|
伪造他人点赞」的计数语义不冲突);页面隐藏时暂停生成,恢复后继续。 -->
|
||||||
<script setup lang="ts">
|
<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);
|
const rootEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -20,11 +30,19 @@ const COLORS = ['#FF6B7A', '#FF8B98'] as const;
|
|||||||
const DRIFTS = ['-8px', '5px', '8px'] as const;
|
const DRIFTS = ['-8px', '5px', '8px'] as const;
|
||||||
const MAX_ACTIVE = 8;
|
const MAX_ACTIVE = 8;
|
||||||
const STAGGER_MS = 300;
|
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() 一致)。 */
|
/** 全局序号(尺寸/颜色/漂移按 n%3 轮转——与效果示例 burst() 一致)。 */
|
||||||
let particleSeq = 0;
|
let particleSeq = 0;
|
||||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
let ambientTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const HEART_SVG =
|
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>';
|
'<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);
|
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({
|
defineExpose({
|
||||||
/** 在 originTop(视口坐标,照片交集下侧)放一颗心(0/1/2/3 颗由调用方
|
/** 在 originTop(视口坐标,照片交集下侧)放一颗心(0/1/2/3 颗由调用方
|
||||||
* 按 heartsForDelta 决定;错峰 300ms)。 */
|
* 按 heartsForDelta 决定;错峰 300ms)。 */
|
||||||
@@ -59,7 +112,7 @@ defineExpose({
|
|||||||
timers.add(timer);
|
timers.add(timer);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** 清空粒子(收起/隐藏/换绑/销毁,§5.3)。 */
|
/** 清空粒子(收起/隐藏/换绑/销毁,§5.3;常驻生成随 ambient 属性停启)。 */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
for (const timer of timers) clearTimeout(timer);
|
for (const timer of timers) clearTimeout(timer);
|
||||||
timers.clear();
|
timers.clear();
|
||||||
@@ -71,6 +124,7 @@ onBeforeUnmount(() => {
|
|||||||
alive = false;
|
alive = false;
|
||||||
for (const timer of timers) clearTimeout(timer);
|
for (const timer of timers) clearTimeout(timer);
|
||||||
timers.clear();
|
timers.clear();
|
||||||
|
stopAmbient();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user