feat: raise the chat panel from the bottom

This commit is contained in:
DiheChen committed 2026-11-05 12:53:55 +08:00
1 parent 7168a97afa
commit 22ddb5f49c
5 files changed
+46 -13

No files matched your search

+2 -4
View File
@@ -19,7 +19,7 @@ import { ErrorBoundary, RegionFailed } from './error-boundary'
import { navigationStore } from './navigation'
import { pwaStore } from './pwa'
import { UpdatePrompt } from './update-prompt'
import { withViewTransition } from './view-transition'
import { panelDirection, withViewTransition } from './view-transition'
// adminStore 不在 main.tsx 中启动:静态导入会把后台页打包进入口 chunk。
const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
@@ -218,10 +218,8 @@ export function App() {
// 加入房间前不显示面板:底部的切换按钮此时不可用,而聊天视图的关闭按钮在 RoomGate 内,打开的聊天面板会无法关闭。
const shownPanel = joined && panel !== page ? panel : null
const switchPanel = (next: Panel | null) => {
// 侧栏 tab 里播放历史排在播放列表右边,切回播放列表时内容要从左边进来。
const direction = shownPanel === 'history' && next === 'queue' ? 'backward' : 'forward'
if (shownPanel && next && next !== shownPanel && next !== page)
withViewTransition(() => navigationStore.setPanel(next), direction)
withViewTransition(() => navigationStore.setPanel(next), panelDirection(shownPanel, next))
else
navigationStore.setPanel(next)
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function DetailPanel({ view, children }: { view: Panel | null, children:
return (
<aside
ref={panelRef}
className={`detail-panel${open ? '' : ' detail-panel-closing'}`}
className={`detail-panel${shown === 'chat' ? ' detail-panel-sheet' : ''}${open ? '' : ' detail-panel-closing'}`}
aria-hidden={!open || undefined}
inert={!open}
onAnimationEnd={(event) => {
+15 -7
View File
@@ -76,18 +76,23 @@
from { opacity: 0; }
to { opacity: 1; }
}
:root { --detail-panel-width: 480px; --view-direction: 1; }
:root[data-view-direction='backward'] { --view-direction: -1; }
:root { --detail-panel-width: 480px; --view-in: translateX(100%); --view-out: translateX(-14%); --view-swap: translateX(10px); }
:root[data-view-direction='backward'] { --view-in: translateX(-100%); --view-out: translateX(14%); --view-swap: translateX(-10px); }
:root[data-view-direction='rise'] { --view-in: translateY(100%); --view-out: translateY(-8%); --view-swap: translateY(10px); }
:root[data-view-direction='sink'] { --view-in: translateY(-8%); --view-out: translateY(100%); --view-swap: translateY(-10px); }
.detail-panel { width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; }
.detail-panel-content { view-transition-name: detail-panel-content; width: var(--detail-panel-width); flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* 只换关键帧里的位移:换 animation-name 会让面板在切换内容时重播打开动画。 */
.detail-panel { --detail-panel-offset: translateX(16px); }
.detail-panel-sheet { --detail-panel-offset: translateY(100%); }
.detail-panel-closing { pointer-events: none; animation-name: detail-panel-close; }
@keyframes detail-panel-open {
from { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
from { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
to { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
}
@keyframes detail-panel-close {
from { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
to { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
to { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
}
/* 视图过渡只移动面板内容:它的过渡分组裁剪在面板内,根快照不做动画,顶栏、主内容和播放器保持不动。 */
::view-transition-group(root) { animation: none; overflow: clip; }
@@ -98,17 +103,20 @@
::view-transition-old(detail-panel-content), ::view-transition-new(detail-panel-content) { background: var(--color-panel); mix-blend-mode: normal; }
::view-transition-old(detail-panel-content) { animation: view-recede 300ms cubic-bezier(.2, .8, .2, 1) both; }
::view-transition-new(detail-panel-content) { animation: view-push-in 300ms cubic-bezier(.2, .8, .2, 1) both; }
/* 聊天在升起和沉下时都要压在列表上面,而新视图默认才在上层。 */
:root[data-view-direction='rise']::view-transition-new(detail-panel-content),
:root[data-view-direction='sink']::view-transition-old(detail-panel-content) { z-index: 1; border-radius: 12px 12px 0 0; box-shadow: 0 -8px 24px var(--color-shadow); }
@keyframes view-recede {
from { transform: translateX(0); }
to { transform: translateX(calc(var(--view-direction) * -14%)); }
to { transform: var(--view-out); }
}
@keyframes view-push-in {
from { transform: translateX(calc(var(--view-direction) * 100%)); }
from { transform: var(--view-in); }
to { transform: translateX(0); }
}
.detail-panel-swap { animation: detail-panel-swap-in 180ms cubic-bezier(.2, .8, .2, 1) both; }
@keyframes detail-panel-swap-in {
from { opacity: 0; transform: translateX(calc(var(--view-direction) * 10px)); }
from { opacity: 0; transform: var(--view-swap); }
to { opacity: 1; transform: translateX(0); }
}
:root[data-performance='low'] .header-menu[open] .main-nav { animation: fade-in 160ms ease both; }
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { panelDirection } from './view-transition'
describe('panelDirection', () => {
it.each([
['queue', 'history', 'forward'],
['history', 'queue', 'backward'],
['queue', 'chat', 'rise'],
['history', 'chat', 'rise'],
['chat', 'queue', 'sink'],
['chat', 'history', 'sink'],
] as const)('%s 到 %s', (from, to, direction) => {
expect(panelDirection(from, to)).toBe(direction)
})
})
+13 -1
View File
@@ -1,11 +1,23 @@
import type { Panel } from './route'
import { flushSync } from 'react-dom'
import { performanceStore } from '@/features/settings'
type ViewDirection = 'forward' | 'backward' | 'rise' | 'sink'
// 播放列表和播放历史是左右并排的 tab;聊天从播放栏升起,盖在它们上面。
export function panelDirection(from: Panel, to: Panel): ViewDirection {
if (to === 'chat')
return 'rise'
if (from === 'chat')
return 'sink'
return to === 'queue' ? 'backward' : 'forward'
}
export function canPushViews(): boolean {
return 'startViewTransition' in document && performanceStore.smoothMotion()
}
export function withViewTransition(update: () => void, direction: 'forward' | 'backward'): void {
export function withViewTransition(update: () => void, direction: ViewDirection): void {
document.documentElement.dataset.viewDirection = direction
if (!canPushViews()) {
update()