调整弹幕姬进入动态
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -56,7 +56,7 @@ WenKai 与漓雨手书。颜色覆盖同样只接受六位十六进制颜色;
|
||||
## 卡片生命周期
|
||||
|
||||
1. 新事件追加在可视区域底部;启用 `expandNewDanmaku`
|
||||
时,普通弹幕以卷轴动画横向展开,否则直接使用紧凑态。旧事件被向上顶出并裁切。
|
||||
时,普通弹幕以卷轴动画横向展开。关闭时,新弹幕保持紧凑态并从组件底边外向上推入,同步顶起旧事件;超出顶部的旧事件会被裁切。
|
||||
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||
|
||||
Reference in New Issue
Block a user