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:
@@ -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>
|
||||
Reference in New Issue
Block a user