调整弹幕姬进入动态

This commit is contained in:
2026-08-19 12:50:19 -07:00
parent 845b0f5900
commit e5186f62bd
3 changed files with 92 additions and 4 deletions
+7
View File
@@ -927,6 +927,13 @@ body,
white-space: normal;
}
/* In compact-only mode the whole bottom-anchored stack is moved with FLIP in
overlay.tsx. Suppress the per-card side entrance so the new row appears to
rise from below the component while the existing rows are pushed upward. */
.overlay.stack-arrival .card.danmaku.compact {
animation: none;
}
.card.danmaku.expanded .copy,
.card.danmaku.compact .copy {
padding-inline-end: clamp(38px, 8%, 58px);
+84 -3
View File
@@ -7,7 +7,7 @@
* component settings, and malformed frames are ignored without terminating a
* long-running browser source.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import type { ComponentStream } from './stream'
import { translate, useI18n } from './i18n'
@@ -335,6 +335,9 @@ function Card({
export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
const { language } = useI18n()
const root = useRef<HTMLDivElement>(null)
const cardsRef = useRef<HTMLDivElement>(null)
const previousCardTopsRef = useRef(new Map<string, number>())
const stackAnimationRef = useRef<Animation | null>(null)
const events = useEvents(preview, stream)
const { items, setItems } = events
const settings = previewSettings || events.settings
@@ -408,10 +411,88 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
return () => window.clearTimeout(timer)
}, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape])
useLayoutEffect(() => {
const cards = cardsRef.current
if (!cards) return
const elements = Array.from(cards.children).filter(
(element): element is HTMLElement => element instanceof HTMLElement,
)
const previousTops = previousCardTopsRef.current
const hasNewDanmaku = items.some(
item => item.type === 'live.danmaku' && !previousTops.has(item.key),
)
// If another push is still running, carry its current offset into the
// next FLIP animation so bursts of chat do not snap between positions.
let runningOffset = 0
const transform = getComputedStyle(cards).transform
if (transform !== 'none') {
try {
runningOffset = new DOMMatrixReadOnly(transform).m42
} catch {
runningOffset = 0
}
}
stackAnimationRef.current?.cancel()
const currentTops = new Map<string, number>()
items.forEach((item, index) => {
const element = elements[index]
if (element) currentTops.set(item.key, element.getBoundingClientRect().top)
})
const reducedMotion =
settings.lowPerformanceMode ||
(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
if (!settings.expandNewDanmaku && hasNewDanmaku && !reducedMotion && elements.length > 0) {
const anchorIndex = items.findIndex(item => previousTops.has(item.key))
const anchor = anchorIndex >= 0 ? elements[anchorIndex] : undefined
const anchorKey = anchorIndex >= 0 ? items[anchorIndex]?.key : undefined
const previousTop = anchorKey ? previousTops.get(anchorKey) : undefined
const currentTop = anchor?.getBoundingClientRect().top
const gap = Number.parseFloat(getComputedStyle(cards).rowGap) || 0
const fallbackOffset = cards.getBoundingClientRect().height + gap
const pushOffset = Math.max(
0,
previousTop !== undefined && currentTop !== undefined
? previousTop + runningOffset - currentTop
: fallbackOffset,
)
if (pushOffset > 0.5) {
const duration = 350 + settings.motionIntensity * 3.5
const animation = cards.animate(
[
{ transform: `translate3d(0, ${pushOffset}px, 0)` },
{ transform: 'translate3d(0, 0, 0)' },
],
{
duration,
easing: 'cubic-bezier(0.2, 0.82, 0.24, 1)',
},
)
stackAnimationRef.current = animation
animation.addEventListener('finish', () => {
if (stackAnimationRef.current === animation) stackAnimationRef.current = null
})
}
}
previousCardTopsRef.current = currentTops
}, [items, settings.expandNewDanmaku, settings.lowPerformanceMode, settings.motionIntensity])
useEffect(
() => () => {
stackAnimationRef.current?.cancel()
},
[],
)
return (
<main
ref={root}
className={`overlay ${theme.className} ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
className={`overlay ${theme.className} ${shape} ${settings.expandNewDanmaku ? '' : 'stack-arrival'} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
style={
@@ -434,7 +515,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
>
<ThemeEdges />
<section className="wall">
<div className="cards">
<div className="cards" ref={cardsRef}>
{items.map(item => (
<Card
item={item}