1. 优化视觉效果、后端规范化
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="danmaku-item"
|
||||
:class="{ 'danmaku-item--grad': row.textLight }"
|
||||
:class="{
|
||||
'danmaku-item--grad': row.isGradient,
|
||||
'danmaku-item--grad-light': row.isGradient && row.textLight,
|
||||
'danmaku-item--grad-dark': row.isGradient && !row.textLight,
|
||||
}"
|
||||
:style="row.style"
|
||||
@animationend="onEnded(row.id)"
|
||||
>
|
||||
@@ -14,7 +18,10 @@
|
||||
<span
|
||||
v-if="showTime && row.timeLabel"
|
||||
class="danmaku-time"
|
||||
:class="{ 'danmaku-time--on-grad': row.textLight }"
|
||||
:class="{
|
||||
'danmaku-time--on-light': row.isGradient && row.textLight,
|
||||
'danmaku-time--on-dark': row.isGradient && !row.textLight,
|
||||
}"
|
||||
>{{ row.timeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,6 +156,7 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
const totalDelay = delay + slot.extraDelay
|
||||
// 5 轨均匀分布在上半屏,轨间距约 14%
|
||||
const topPct = 7 + slot.track * 14
|
||||
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
|
||||
|
||||
const timeLabel = force
|
||||
? '刚刚'
|
||||
@@ -160,6 +168,7 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
content: item.content,
|
||||
color: resolveDanmakuColor(item.color),
|
||||
textLight: !!tint.textLight,
|
||||
isGradient,
|
||||
timeLabel,
|
||||
style: {
|
||||
top: `${topPct}%`,
|
||||
@@ -338,10 +347,15 @@ defineExpose({ refresh: fetchList, pushItem })
|
||||
max-width: none;
|
||||
}
|
||||
.danmaku-item--grad {
|
||||
backdrop-filter: saturate(1.25) blur(8px);
|
||||
text-shadow: 0 1px 2px rgba(40, 20, 30, 0.28);
|
||||
backdrop-filter: saturate(1.2) blur(8px);
|
||||
font-weight: 500;
|
||||
}
|
||||
.danmaku-item--grad-light {
|
||||
text-shadow: 0 1px 2px rgba(20, 10, 20, 0.45), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.danmaku-item--grad-dark {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.danmaku-name { font-weight: 600; }
|
||||
.danmaku-sep { opacity: 0.9; }
|
||||
.danmaku-text { opacity: 0.95; }
|
||||
@@ -351,8 +365,11 @@ defineExpose({ refresh: fetchList, pushItem })
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.danmaku-time--on-grad {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
.danmaku-time--on-light {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
.danmaku-time--on-dark {
|
||||
color: rgba(74, 52, 32, 0.72);
|
||||
}
|
||||
@keyframes danmaku-fly {
|
||||
from { transform: translateX(0); }
|
||||
|
||||
@@ -1,88 +1,472 @@
|
||||
<template>
|
||||
<div class="like-burst" aria-hidden="true">
|
||||
<span
|
||||
v-for="h in hearts"
|
||||
:key="h.id"
|
||||
class="like-burst-heart"
|
||||
:style="h.style"
|
||||
@animationend="removeHeart(h.id)"
|
||||
>♥</span>
|
||||
</div>
|
||||
<canvas ref="canvasRef" class="like-burst-canvas" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const hearts = ref([])
|
||||
let uid = 0
|
||||
const canvasRef = ref(null)
|
||||
|
||||
/** 单次最多同时排队的爱心数,避免超高点赞拖垮渲染 */
|
||||
const MAX_BURST = 80
|
||||
/** 同屏上限 */
|
||||
const MAX_HEARTS = 55
|
||||
const MAX_SPARKS = 120
|
||||
/** 异常保护 */
|
||||
const MAX_TOTAL = 5000
|
||||
/** 每次「点赞效果」间隔 */
|
||||
const LIKE_GAP_MS = 280
|
||||
/** 与手动点赞一致:每次效果飘出几颗 */
|
||||
const HEARTS_PER_LIKE = 1
|
||||
|
||||
const burst = (count = 3) => {
|
||||
const n = Math.max(0, Math.min(MAX_BURST, Math.floor(Number(count) || 0)))
|
||||
if (!n) return
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const id = ++uid
|
||||
const left = 10 + Math.random() * 200
|
||||
const drift = (Math.random() - 0.5) * 90
|
||||
const duration = 2.8 + Math.random() * 1.0
|
||||
const delay = i * 0.1
|
||||
const scale = 0.8 + Math.random() * 0.55
|
||||
hearts.value.push({
|
||||
id,
|
||||
style: {
|
||||
left: `${left}px`,
|
||||
'--drift': `${drift}px`,
|
||||
'--scale': scale,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
},
|
||||
})
|
||||
const HEART_MIN_SIZE = 14
|
||||
const HEART_MAX_SIZE = 34
|
||||
const HEART_MIN_LIFE = 1400
|
||||
const HEART_MAX_LIFE = 2600
|
||||
const RISE_SPEED_MIN = 70
|
||||
const RISE_SPEED_MAX = 150
|
||||
const DRIFT_AMP_MIN = 8
|
||||
const DRIFT_AMP_MAX = 45
|
||||
const GLOW_BLUR = 10
|
||||
const GLOW_ALPHA = 0.55
|
||||
const DPR_CAP = 2
|
||||
|
||||
/** 多色爱心:主色 */
|
||||
const HEART_COLORS = [
|
||||
'#ff2442', '#ff2d55', '#ff4770', '#ff6b8a',
|
||||
'#f472b6', '#ec4899', '#e879f9', '#c084fc',
|
||||
'#a78bfa', '#818cf8', '#60a5fa', '#38bdf8',
|
||||
'#2dd4bf', '#34d399', '#fbbf24', '#f59e0b',
|
||||
'#fb923c', '#a88c6b', '#d4a574', '#e8b4b8',
|
||||
'#f43f5e', '#fb7185', '#fda4af',
|
||||
]
|
||||
|
||||
/** 高光色(更亮) */
|
||||
const GLOW_COLORS = [
|
||||
'#ff8da6', '#ffb0c4', '#f9a8d4', '#e9d5ff',
|
||||
'#c4b5fd', '#93c5fd', '#6ee7b7', '#fde68a',
|
||||
'#fdba74', '#f5d0b0', '#ffffff',
|
||||
]
|
||||
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]
|
||||
const rand = (min, max) => min + Math.random() * (max - min)
|
||||
|
||||
let ctx = null
|
||||
let W = 0
|
||||
let H = 0
|
||||
let dpr = 1
|
||||
let hearts = []
|
||||
let sparks = []
|
||||
let rafId = 0
|
||||
let running = false
|
||||
|
||||
let targetLikes = 0
|
||||
let firedLikes = 0
|
||||
let likeTimer = null
|
||||
|
||||
const clearLikeTimer = () => {
|
||||
clearTimeout(likeTimer)
|
||||
likeTimer = null
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
W = rect.width
|
||||
H = rect.height
|
||||
canvas.width = Math.max(1, Math.floor(W * dpr))
|
||||
canvas.height = Math.max(1, Math.floor(H * dpr))
|
||||
ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准爱心路径(以 cx,cy 为视觉中心)
|
||||
* 双瓣二次曲线 + 底部尖角
|
||||
*/
|
||||
function drawHeartPath(context, cx, cy, size) {
|
||||
// size ≈ 半宽;整体高约 1.5*size
|
||||
const w = size * 2.1
|
||||
const h = size * 1.95
|
||||
const top = cy - h * 0.42
|
||||
const midY = top + h * 0.28
|
||||
const tipY = top + h
|
||||
|
||||
context.beginPath()
|
||||
// 从顶部凹口中间出发
|
||||
context.moveTo(cx, midY)
|
||||
// 左上瓣
|
||||
context.bezierCurveTo(
|
||||
cx, top,
|
||||
cx - w * 0.5, top,
|
||||
cx - w * 0.5, midY,
|
||||
)
|
||||
// 左下到尖端
|
||||
context.bezierCurveTo(
|
||||
cx - w * 0.5, top + h * 0.62,
|
||||
cx - w * 0.08, tipY - h * 0.12,
|
||||
cx, tipY,
|
||||
)
|
||||
// 右下到右瓣
|
||||
context.bezierCurveTo(
|
||||
cx + w * 0.08, tipY - h * 0.12,
|
||||
cx + w * 0.5, top + h * 0.62,
|
||||
cx + w * 0.5, midY,
|
||||
)
|
||||
// 右上瓣回到凹口
|
||||
context.bezierCurveTo(
|
||||
cx + w * 0.5, top,
|
||||
cx, top,
|
||||
cx, midY,
|
||||
)
|
||||
context.closePath()
|
||||
}
|
||||
|
||||
function drawGlowHeart(context, cx, cy, size, color, glowColor, opacity, glowAlpha) {
|
||||
if (opacity <= 0.01 || size <= 1) return
|
||||
const globalAlpha = Math.min(opacity, 1)
|
||||
|
||||
context.save()
|
||||
context.globalAlpha = globalAlpha
|
||||
|
||||
if (glowAlpha > 0.01) {
|
||||
context.save()
|
||||
context.shadowColor = glowColor
|
||||
context.shadowBlur = GLOW_BLUR * (size / 18)
|
||||
context.fillStyle = glowColor
|
||||
context.globalAlpha = glowAlpha * globalAlpha
|
||||
drawHeartPath(context, cx, cy, size)
|
||||
context.fill()
|
||||
context.restore()
|
||||
}
|
||||
|
||||
// 主体:从上到下的轻微立体渐变
|
||||
const gradient = context.createLinearGradient(cx, cy - size * 0.6, cx, cy + size * 0.7)
|
||||
gradient.addColorStop(0, glowColor)
|
||||
gradient.addColorStop(0.4, color)
|
||||
gradient.addColorStop(1, shadeColor(color, -0.28))
|
||||
|
||||
context.fillStyle = gradient
|
||||
context.shadowColor = 'transparent'
|
||||
context.shadowBlur = 0
|
||||
drawHeartPath(context, cx, cy, size)
|
||||
context.fill()
|
||||
|
||||
// 左上高光
|
||||
const highlightX = cx - size * 0.22
|
||||
const highlightY = cy - size * 0.28
|
||||
const highlightR = size * 0.18
|
||||
context.save()
|
||||
context.globalAlpha = 0.45 * globalAlpha
|
||||
const hlGrad = context.createRadialGradient(
|
||||
highlightX, highlightY, 0,
|
||||
highlightX, highlightY, highlightR,
|
||||
)
|
||||
hlGrad.addColorStop(0, 'rgba(255,255,255,0.95)')
|
||||
hlGrad.addColorStop(0.55, 'rgba(255,255,255,0.25)')
|
||||
hlGrad.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
context.fillStyle = hlGrad
|
||||
context.beginPath()
|
||||
context.arc(highlightX, highlightY, highlightR, 0, Math.PI * 2)
|
||||
context.fill()
|
||||
context.restore()
|
||||
|
||||
context.restore()
|
||||
}
|
||||
|
||||
/** 简单加深/变亮 hex */
|
||||
function shadeColor(hex, amount) {
|
||||
const h = hex.replace('#', '')
|
||||
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
|
||||
const n = parseInt(full, 16)
|
||||
let r = (n >> 16) & 255
|
||||
let g = (n >> 8) & 255
|
||||
let b = n & 255
|
||||
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)))
|
||||
r = clamp(r + r * amount)
|
||||
g = clamp(g + g * amount)
|
||||
b = clamp(b + b * amount)
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
|
||||
function createHeart(x, y, now) {
|
||||
return {
|
||||
startX: x,
|
||||
startY: y,
|
||||
birthTime: now,
|
||||
maxAge: rand(HEART_MIN_LIFE, HEART_MAX_LIFE),
|
||||
baseSize: rand(HEART_MIN_SIZE, HEART_MAX_SIZE),
|
||||
color: pick(HEART_COLORS),
|
||||
glowColor: pick(GLOW_COLORS),
|
||||
peakScale: rand(0.75, 1.35),
|
||||
riseSpeed: rand(RISE_SPEED_MIN, RISE_SPEED_MAX),
|
||||
driftAmp: rand(DRIFT_AMP_MIN, DRIFT_AMP_MAX),
|
||||
driftFreq: rand(1.2, 2.8),
|
||||
driftPhase: rand(0, Math.PI * 2),
|
||||
rotation: rand(-0.4, 0.4),
|
||||
rotationSpeed: rand(-0.35, 0.35),
|
||||
extraDrift: Math.random() < 0.25 ? rand(-25, 25) : 0,
|
||||
spawnedSparks: false,
|
||||
}
|
||||
}
|
||||
|
||||
const removeHeart = (id) => {
|
||||
hearts.value = hearts.value.filter((h) => h.id !== id)
|
||||
function heartProgress(h, now) {
|
||||
return Math.min((now - h.birthTime) / h.maxAge, 1)
|
||||
}
|
||||
|
||||
function heartScale(h, now) {
|
||||
const progress = heartProgress(h, now)
|
||||
if (progress < 0.18) {
|
||||
const t = progress / 0.18
|
||||
const elastic = 1 - (1 - t) ** 3.5
|
||||
const overshoot = Math.sin(t * Math.PI) * 0.08 * (1 - t)
|
||||
return h.peakScale * (elastic + overshoot)
|
||||
}
|
||||
if (progress < 0.65) {
|
||||
const t = (progress - 0.18) / 0.47
|
||||
return h.peakScale * (1 - t * 0.25)
|
||||
}
|
||||
const t = (progress - 0.65) / 0.35
|
||||
return h.peakScale * 0.75 * (1 - t * t)
|
||||
}
|
||||
|
||||
function heartOpacity(h, now) {
|
||||
const progress = heartProgress(h, now)
|
||||
if (progress < 0.45) return 1
|
||||
if (progress < 0.78) return 1 - ((progress - 0.45) / 0.33) * 0.35
|
||||
const t = (progress - 0.78) / 0.22
|
||||
return 0.65 * (1 - t * t)
|
||||
}
|
||||
|
||||
function heartGlowAlpha(h, now) {
|
||||
const progress = heartProgress(h, now)
|
||||
if (progress < 0.5) return GLOW_ALPHA
|
||||
if (progress < 0.8) return GLOW_ALPHA * (1 - (progress - 0.5) / 0.3)
|
||||
return 0
|
||||
}
|
||||
|
||||
function heartY(h, now) {
|
||||
const ageSec = (now - h.birthTime) / 1000
|
||||
const progress = heartProgress(h, now)
|
||||
const speedMultiplier = 1 + (progress > 0.7 ? ((progress - 0.7) / 0.3) * 0.4 : 0)
|
||||
return h.startY - h.riseSpeed * ageSec * speedMultiplier
|
||||
}
|
||||
|
||||
function heartX(h, now) {
|
||||
const ageSec = (now - h.birthTime) / 1000
|
||||
const sinDrift = Math.sin(ageSec * h.driftFreq + h.driftPhase) * h.driftAmp
|
||||
const linearDrift = h.extraDrift * Math.min(ageSec / 1.5, 1)
|
||||
return h.startX + sinDrift + linearDrift
|
||||
}
|
||||
|
||||
function createSpark(x, y, color, now) {
|
||||
const angle = rand(0, Math.PI * 2)
|
||||
const speed = rand(25, 90)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed - rand(15, 50),
|
||||
birthTime: now,
|
||||
life: rand(250, 550),
|
||||
color,
|
||||
size: rand(1.5, 4.5),
|
||||
}
|
||||
}
|
||||
|
||||
function spawnSparksForHeart(heart, now) {
|
||||
if (heart.spawnedSparks) return
|
||||
heart.spawnedSparks = true
|
||||
const cx = heartX(heart, now)
|
||||
const cy = heartY(heart, now)
|
||||
const sparkCount = Math.floor(rand(3, 7))
|
||||
for (let i = 0; i < sparkCount; i += 1) {
|
||||
if (sparks.length >= MAX_SPARKS) sparks.shift()
|
||||
sparks.push(createSpark(cx, cy, heart.glowColor, now))
|
||||
}
|
||||
}
|
||||
|
||||
function spawnHeartAt(x, y, now) {
|
||||
if (hearts.length >= MAX_HEARTS) {
|
||||
const oldest = hearts.shift()
|
||||
if (oldest && !oldest.spawnedSparks) spawnSparksForHeart(oldest, now)
|
||||
}
|
||||
hearts.push(createHeart(x, y, now))
|
||||
ensureLoop()
|
||||
}
|
||||
|
||||
/** 一次点赞效果:底部偏左区域冒出多颗彩色爱心 */
|
||||
function fireOneLikeEffect() {
|
||||
if (hearts.length >= MAX_HEARTS) return false
|
||||
const now = performance.now()
|
||||
const baseX = W * (0.18 + Math.random() * 0.35)
|
||||
const baseY = H * (0.78 + Math.random() * 0.12)
|
||||
const room = MAX_HEARTS - hearts.length
|
||||
const n = Math.min(HEARTS_PER_LIKE, room)
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
spawnHeartAt(
|
||||
baseX + rand(-28, 28),
|
||||
baseY + rand(-18, 18),
|
||||
now + i * 20,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const scheduleNextLike = () => {
|
||||
clearLikeTimer()
|
||||
if (firedLikes >= targetLikes) return
|
||||
likeTimer = setTimeout(() => {
|
||||
likeTimer = null
|
||||
if (firedLikes >= targetLikes) return
|
||||
if (!fireOneLikeEffect()) {
|
||||
scheduleNextLike()
|
||||
return
|
||||
}
|
||||
firedLikes += 1
|
||||
if (firedLikes < targetLikes) scheduleNextLike()
|
||||
}, LIKE_GAP_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
|
||||
*/
|
||||
const burst = (count = 3) => {
|
||||
clearLikeTimer()
|
||||
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
|
||||
if (!n) return
|
||||
if (!W || !H) resizeCanvas()
|
||||
targetLikes = n
|
||||
firedLikes = 0
|
||||
if (fireOneLikeEffect()) firedLikes = 1
|
||||
if (firedLikes < targetLikes) scheduleNextLike()
|
||||
}
|
||||
|
||||
function ensureLoop() {
|
||||
if (running) return
|
||||
running = true
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function stopLoopIfIdle() {
|
||||
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) return
|
||||
running = false
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = 0
|
||||
}
|
||||
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
function animate(timestamp) {
|
||||
if (!running) return
|
||||
const now = timestamp || performance.now()
|
||||
if (!ctx) {
|
||||
rafId = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
for (let i = sparks.length - 1; i >= 0; i -= 1) {
|
||||
const spark = sparks[i]
|
||||
const progress = Math.min((now - spark.birthTime) / spark.life, 1)
|
||||
if (progress >= 1) {
|
||||
sparks.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const age = (now - spark.birthTime) / 1000
|
||||
const sx = spark.x + spark.vx * age
|
||||
const sy = spark.y + spark.vy * age + 0.5 * 80 * age * age
|
||||
const opacity = 1 - progress * progress
|
||||
const size = spark.size * (1 - progress * 0.6)
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity
|
||||
ctx.fillStyle = spark.color
|
||||
ctx.shadowColor = spark.color
|
||||
ctx.shadowBlur = 4
|
||||
ctx.beginPath()
|
||||
ctx.arc(sx, sy, size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
for (let i = hearts.length - 1; i >= 0; i -= 1) {
|
||||
const heart = hearts[i]
|
||||
const progress = heartProgress(heart, now)
|
||||
|
||||
if (!heart.spawnedSparks && progress > 0.82) {
|
||||
spawnSparksForHeart(heart, now)
|
||||
}
|
||||
|
||||
if (progress >= 1) {
|
||||
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
|
||||
hearts.splice(i, 1)
|
||||
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
|
||||
continue
|
||||
}
|
||||
|
||||
const cx = heartX(heart, now)
|
||||
const cy = heartY(heart, now)
|
||||
const scale = heartScale(heart, now)
|
||||
const size = heart.baseSize * scale
|
||||
const opacity = heartOpacity(heart, now)
|
||||
const glowAlpha = heartGlowAlpha(heart, now)
|
||||
const rotation = heart.rotation + heart.rotationSpeed * ((now - heart.birthTime) / 1000)
|
||||
|
||||
if (cy < -size * 2 || cy > H + size * 2 || cx < -size * 2 || cx > W + size * 2) {
|
||||
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
|
||||
hearts.splice(i, 1)
|
||||
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
|
||||
continue
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(cx, cy)
|
||||
ctx.rotate(rotation)
|
||||
drawGlowHeart(ctx, 0, 0, size, heart.color, heart.glowColor, opacity, glowAlpha)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
while (sparks.length > MAX_SPARKS) sparks.shift()
|
||||
|
||||
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) {
|
||||
rafId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
stopLoopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
const onResize = () => resizeCanvas()
|
||||
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearLikeTimer()
|
||||
running = false
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
hearts = []
|
||||
sparks = []
|
||||
})
|
||||
|
||||
defineExpose({ burst })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.like-burst {
|
||||
.like-burst-canvas {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(46vw, 280px);
|
||||
height: 58vh;
|
||||
width: min(52vw, 320px);
|
||||
height: 62vh;
|
||||
z-index: 45;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.like-burst-heart {
|
||||
position: absolute;
|
||||
bottom: 28px;
|
||||
color: #f472b6;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 8px rgba(244, 114, 182, 0.45);
|
||||
animation-name: like-rise;
|
||||
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes like-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 12px, 0) scale(calc(var(--scale, 1) * 0.6));
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--drift, 0px), -55vh, 0) scale(var(--scale, 1));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user