feat: raise the chat panel from the bottom
This commit is contained in:
1 parent
7168a97afa
commit
22ddb5f49c
5 files changed
+46
-13
No files matched your search
+2
-4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user