调整弹幕姬进入动态

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; 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.expanded .copy,
.card.danmaku.compact .copy { .card.danmaku.compact .copy {
padding-inline-end: clamp(38px, 8%, 58px); 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 * component settings, and malformed frames are ignored without terminating a
* long-running browser source. * long-running browser source.
*/ */
import { useEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import type { ComponentStream } from './stream' import type { ComponentStream } from './stream'
import { translate, useI18n } from './i18n' import { translate, useI18n } from './i18n'
@@ -335,6 +335,9 @@ function Card({
export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) { export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
const { language } = useI18n() const { language } = useI18n()
const root = useRef<HTMLDivElement>(null) 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 events = useEvents(preview, stream)
const { items, setItems } = events const { items, setItems } = events
const settings = previewSettings || events.settings const settings = previewSettings || events.settings
@@ -408,10 +411,88 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape]) }, [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 ( return (
<main <main
ref={root} 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-theme={theme.id}
data-connection={stream?.connection || 'idle'} data-connection={stream?.connection || 'idle'}
style={ style={
@@ -434,7 +515,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
> >
<ThemeEdges /> <ThemeEdges />
<section className="wall"> <section className="wall">
<div className="cards"> <div className="cards" ref={cardsRef}>
{items.map(item => ( {items.map(item => (
<Card <Card
item={item} item={item}
+1 -1
View File
@@ -56,7 +56,7 @@ WenKai 与漓雨手书。颜色覆盖同样只接受六位十六进制颜色;
## 卡片生命周期 ## 卡片生命周期
1. 新事件追加在可视区域底部;启用 `expandNewDanmaku` 1. 新事件追加在可视区域底部;启用 `expandNewDanmaku`
时,普通弹幕以卷轴动画横向展开,否则直接使用紧凑态。旧事件被向上顶出并裁切。 时,普通弹幕以卷轴动画横向展开。关闭时,新弹幕保持紧凑态并从组件底边外向上推入,同步顶起旧事件;超出顶部的旧事件会被裁切。
2. 用户名与内容在展开态分行显示,长内容完整换行。 2. 用户名与内容在展开态分行显示,长内容完整换行。
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。 3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。 4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。