mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Smooth sidebar transitions and reduce terminal jitter (#433)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
|
||||
import { isGitRepoKind } from '../../shared/repo-kind'
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { PersistedOpenFile } from '../../shared/types'
|
||||
import type { OpenFile } from './store/slices/editor'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const SIDEBAR_TRANSITION_MS = 200
|
||||
|
||||
/** Build the editor-file portion of the workspace session for persistence.
|
||||
* Only edit-mode files are saved — diffs and conflict views are transient. */
|
||||
@@ -126,6 +127,7 @@ function App(): React.JSX.Element {
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const setQuickOpenVisible = useAppStore((s) => s.setQuickOpenVisible)
|
||||
const isFullScreen = useAppStore((s) => s.isFullScreen)
|
||||
const hasSeenInitialSidebarStateRef = useRef(false)
|
||||
|
||||
// Subscribe to IPC push events
|
||||
useIpcEvents()
|
||||
@@ -299,6 +301,27 @@ function App(): React.JSX.Element {
|
||||
return () => window.removeEventListener('beforeunload', captureAndFlush)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!persistedUIReady) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasSeenInitialSidebarStateRef.current) {
|
||||
hasSeenInitialSidebarStateRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the terminal's WebGL renderer can flash blank while the app shell
|
||||
// animates sidebar widths. Broadcasting the transition window lets active
|
||||
// terminals temporarily fall back to the DOM renderer just for that
|
||||
// animation, then restore GPU rendering after the layout settles.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('orca-layout-transition', {
|
||||
detail: { durationMs: SIDEBAR_TRANSITION_MS }
|
||||
})
|
||||
)
|
||||
}, [persistedUIReady, sidebarOpen, rightSidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!persistedUIReady) {
|
||||
return
|
||||
@@ -575,7 +598,11 @@ function App(): React.JSX.Element {
|
||||
{activeView === 'settings' ? <Settings /> : !activeWorktreeId ? <Landing /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showSidebar && rightSidebarOpen ? <RightSidebar /> : null}
|
||||
{/* Why: the right sidebar stays mounted even while "closed" so its
|
||||
width can animate from 0px to the saved width. Unmounting here made
|
||||
the panel pop in abruptly instead of matching the left sidebar's
|
||||
smooth expand/collapse behavior. */}
|
||||
{showSidebar ? <RightSidebar /> : null}
|
||||
</div>
|
||||
<QuickOpen />
|
||||
<ZoomOverlay />
|
||||
|
||||
@@ -138,15 +138,16 @@ export default function RightSidebar(): React.JSX.Element {
|
||||
: visibleItems[0].id
|
||||
|
||||
const activityBarSideWidth = activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0
|
||||
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: rightSidebarOpen,
|
||||
width: rightSidebarWidth,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
deltaSign: -1,
|
||||
renderedExtraWidth: activityBarSideWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
})
|
||||
const { containerRef, onResizeStart, renderedOpen, contentAnimationState } =
|
||||
useSidebarResize<HTMLDivElement>({
|
||||
isOpen: rightSidebarOpen,
|
||||
width: rightSidebarWidth,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
deltaSign: -1,
|
||||
renderedExtraWidth: activityBarSideWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
})
|
||||
|
||||
const panelContent = (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
|
||||
@@ -171,54 +172,73 @@ export default function RightSidebar(): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden={!rightSidebarOpen}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 flex flex-row overflow-visible',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-200'
|
||||
!renderedOpen && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{/* Panel content area */}
|
||||
<div
|
||||
className="flex flex-col flex-1 min-w-0 bg-sidebar overflow-hidden"
|
||||
style={{
|
||||
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
|
||||
borderLeft: renderedOpen ? '1px solid var(--sidebar-border)' : 'none'
|
||||
}}
|
||||
>
|
||||
{activityBarPosition === 'top' ? (
|
||||
/* ── Top activity bar: horizontal icon row ── */
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex items-center border-b border-border h-[33px] min-h-[33px] px-1">
|
||||
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ActivityBarPositionMenu
|
||||
currentPosition={activityBarPosition}
|
||||
onChangePosition={setActivityBarPosition}
|
||||
/>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
/* ── Side layout: static title header ── */
|
||||
<div className="flex items-center h-[33px] min-h-[33px] px-3 border-b border-border">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
|
||||
{visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col transition-[transform,opacity] duration-200 ease-out',
|
||||
contentAnimationState === 'open' || contentAnimationState === 'opening'
|
||||
? 'translate-x-0 opacity-100'
|
||||
: 'translate-x-3 opacity-0'
|
||||
)}
|
||||
>
|
||||
{activityBarPosition === 'top' ? (
|
||||
/* ── Top activity bar: horizontal icon row ── */
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex items-center border-b border-border h-[33px] min-h-[33px] px-1">
|
||||
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ActivityBarPositionMenu
|
||||
currentPosition={activityBarPosition}
|
||||
onChangePosition={setActivityBarPosition}
|
||||
/>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
/* ── Side layout: static title header ── */
|
||||
<div className="flex items-center h-[33px] min-h-[33px] px-3 border-b border-border">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
|
||||
{visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelContent}
|
||||
{panelContent}
|
||||
</div>
|
||||
|
||||
{/* Resize handle on LEFT side */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
{renderedOpen ? (
|
||||
<div
|
||||
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Side Activity Bar (icon strip on right edge) — only for 'side' position */}
|
||||
{activityBarPosition === 'side' && (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border transition-[transform,opacity] duration-200 ease-out',
|
||||
contentAnimationState === 'open' || contentAnimationState === 'opening'
|
||||
? 'translate-x-0 opacity-100'
|
||||
: 'translate-x-3 opacity-0'
|
||||
)}
|
||||
>
|
||||
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -33,43 +33,52 @@ export default function Sidebar(): React.JSX.Element {
|
||||
}
|
||||
}, [repoCount, fetchAllWorktrees])
|
||||
|
||||
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: sidebarOpen,
|
||||
width: sidebarWidth,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
deltaSign: 1,
|
||||
setWidth: setSidebarWidth
|
||||
})
|
||||
const { containerRef, onResizeStart, renderedOpen, contentAnimationState } =
|
||||
useSidebarResize<HTMLDivElement>({
|
||||
isOpen: sidebarOpen,
|
||||
width: sidebarWidth,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
deltaSign: 1,
|
||||
setWidth: setSidebarWidth
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-200'
|
||||
)}
|
||||
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent"
|
||||
style={{
|
||||
borderRight: sidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
|
||||
borderRight: renderedOpen ? '1px solid var(--sidebar-border)' : 'none'
|
||||
}}
|
||||
>
|
||||
{/* Fixed controls */}
|
||||
<SidebarHeader />
|
||||
<SearchBar />
|
||||
<GroupControls />
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col transition-[transform,opacity] duration-200 ease-out',
|
||||
contentAnimationState === 'open' || contentAnimationState === 'opening'
|
||||
? 'translate-x-0 opacity-100'
|
||||
: '-translate-x-3 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Fixed controls */}
|
||||
<SidebarHeader />
|
||||
<SearchBar />
|
||||
<GroupControls />
|
||||
|
||||
{/* Virtualized scrollable list */}
|
||||
<WorktreeList />
|
||||
{/* Virtualized scrollable list */}
|
||||
<WorktreeList />
|
||||
|
||||
{/* Fixed bottom toolbar */}
|
||||
<SidebarToolbar />
|
||||
{/* Fixed bottom toolbar */}
|
||||
<SidebarToolbar />
|
||||
</div>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
{renderedOpen ? (
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Dialog (rendered outside sidebar to avoid clipping) */}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
|
||||
function fitAndRefreshPane(pane: ManagedPane): void {
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
// Why: width animations from the left/right sidebars can leave xterm's
|
||||
// renderer showing only the pane background until some later paint or PTY
|
||||
// write arrives. Forcing a viewport refresh after fit keeps the existing
|
||||
// scrollback visible throughout the transition instead of flashing blank.
|
||||
if (pane.terminal.rows > 0) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function fitPanes(manager: PaneManager): void {
|
||||
for (const pane of manager.getPanes()) {
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fitAndRefreshPane(pane)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,56 @@ export function useTerminalPaneGlobalEffects({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
let restoreTimer: number | null = null
|
||||
const restoreGpuRendering = (): void => {
|
||||
if (restoreTimer !== null) {
|
||||
window.clearTimeout(restoreTimer)
|
||||
restoreTimer = null
|
||||
}
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
manager.resumeRendering()
|
||||
fitPanes(manager)
|
||||
}
|
||||
|
||||
const onLayoutTransition = (event: Event): void => {
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
((event as CustomEvent<{ durationMs?: number }>).detail?.durationMs ?? 0) + 34
|
||||
)
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
// Why: xterm's WebGL renderer can briefly clear to the pane background
|
||||
// during animated sidebar width changes even though the terminal itself
|
||||
// is still alive. Suspending GPU rendering for just the transition window
|
||||
// keeps the DOM renderer in charge while the layout is unstable, then we
|
||||
// restore WebGL after the animation finishes.
|
||||
manager.suspendRendering()
|
||||
fitPanes(manager)
|
||||
if (restoreTimer !== null) {
|
||||
window.clearTimeout(restoreTimer)
|
||||
}
|
||||
restoreTimer = window.setTimeout(restoreGpuRendering, durationMs)
|
||||
}
|
||||
|
||||
window.addEventListener('orca-layout-transition', onLayoutTransition)
|
||||
return () => {
|
||||
window.removeEventListener('orca-layout-transition', onLayoutTransition)
|
||||
if (restoreTimer !== null) {
|
||||
window.clearTimeout(restoreTimer)
|
||||
}
|
||||
}
|
||||
}, [isActive, managerRef])
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.ui.onFileDrop(({ path, target }) => {
|
||||
if (!isActiveRef.current || target !== 'terminal') {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clampSidebarResizeWidth,
|
||||
getNextSidebarResizeWidth,
|
||||
getRenderedSidebarWidthCssValue
|
||||
getRenderedSidebarWidthCssValue,
|
||||
getRenderedSidebarWidthPx,
|
||||
interpolateSidebarAnimationWidth
|
||||
} from './useSidebarResize'
|
||||
|
||||
describe('useSidebarResize helpers', () => {
|
||||
@@ -63,8 +65,19 @@ describe('useSidebarResize helpers', () => {
|
||||
})
|
||||
|
||||
it('renders closed sidebars at zero width and open sidebars with extra width', () => {
|
||||
expect(getRenderedSidebarWidthPx(false, 280, 40)).toBe(0)
|
||||
expect(getRenderedSidebarWidthPx(true, 280, 0)).toBe(280)
|
||||
expect(getRenderedSidebarWidthPx(true, 280, 40)).toBe(320)
|
||||
expect(getRenderedSidebarWidthCssValue(false, 280, 40)).toBe('0px')
|
||||
expect(getRenderedSidebarWidthCssValue(true, 280, 0)).toBe('280px')
|
||||
expect(getRenderedSidebarWidthCssValue(true, 280, 40)).toBe('320px')
|
||||
})
|
||||
|
||||
it('interpolates sidebar toggle widths with clamped easing progress', () => {
|
||||
expect(interpolateSidebarAnimationWidth(0, 320, -1)).toBe(0)
|
||||
expect(interpolateSidebarAnimationWidth(0, 320, 0)).toBe(0)
|
||||
expect(interpolateSidebarAnimationWidth(0, 320, 1)).toBe(320)
|
||||
expect(interpolateSidebarAnimationWidth(0, 320, 2)).toBe(320)
|
||||
expect(interpolateSidebarAnimationWidth(0, 320, 0.5)).toBe(160)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
const SIDEBAR_TOGGLE_ANIMATION_MS = 200
|
||||
|
||||
export type SidebarContentAnimationState = 'opening' | 'open' | 'closing' | 'closed'
|
||||
|
||||
type UseSidebarResizeOptions = {
|
||||
isOpen: boolean
|
||||
width: number
|
||||
@@ -14,6 +18,8 @@ type UseSidebarResizeResult<T extends HTMLElement> = {
|
||||
containerRef: React.RefObject<T | null>
|
||||
isResizing: boolean
|
||||
onResizeStart: (event: React.MouseEvent) => void
|
||||
renderedOpen: boolean
|
||||
contentAnimationState: SidebarContentAnimationState
|
||||
}
|
||||
|
||||
export function clampSidebarResizeWidth(width: number, minWidth: number, maxWidth: number): number {
|
||||
@@ -25,7 +31,28 @@ export function getRenderedSidebarWidthCssValue(
|
||||
width: number,
|
||||
renderedExtraWidth: number
|
||||
): string {
|
||||
return isOpen ? `${width + renderedExtraWidth}px` : '0px'
|
||||
return `${getRenderedSidebarWidthPx(isOpen, width, renderedExtraWidth)}px`
|
||||
}
|
||||
|
||||
export function getRenderedSidebarWidthPx(
|
||||
isOpen: boolean,
|
||||
width: number,
|
||||
renderedExtraWidth: number
|
||||
): number {
|
||||
return isOpen ? width + renderedExtraWidth : 0
|
||||
}
|
||||
|
||||
export function interpolateSidebarAnimationWidth(
|
||||
startWidth: number,
|
||||
endWidth: number,
|
||||
progress: number
|
||||
): number {
|
||||
const clampedProgress = Math.min(1, Math.max(0, progress))
|
||||
const easedProgress =
|
||||
clampedProgress < 0.5
|
||||
? 4 * clampedProgress * clampedProgress * clampedProgress
|
||||
: 1 - Math.pow(-2 * clampedProgress + 2, 3) / 2
|
||||
return startWidth + (endWidth - startWidth) * easedProgress
|
||||
}
|
||||
|
||||
export function getNextSidebarResizeWidth({
|
||||
@@ -62,7 +89,13 @@ export function useSidebarResize<T extends HTMLElement>({
|
||||
const startWidthRef = useRef(width)
|
||||
const draftWidthRef = useRef(width)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const openFrameRef = useRef<number | null>(null)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [renderedOpen, setRenderedOpen] = useState(isOpen)
|
||||
const [contentAnimationState, setContentAnimationState] = useState<SidebarContentAnimationState>(
|
||||
isOpen ? 'open' : 'closed'
|
||||
)
|
||||
|
||||
const resetDocumentStyles = useCallback(() => {
|
||||
document.body.style.cursor = ''
|
||||
@@ -70,7 +103,7 @@ export function useSidebarResize<T extends HTMLElement>({
|
||||
}, [])
|
||||
|
||||
const applyRenderedWidth = useCallback(
|
||||
(nextWidth: number) => {
|
||||
(nextWidth: number, nextIsOpen: boolean = renderedOpen) => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
@@ -80,9 +113,13 @@ export function useSidebarResize<T extends HTMLElement>({
|
||||
// React props. Any unrelated rerender during a drag would otherwise
|
||||
// snap the DOM width back to the last persisted store value and make the
|
||||
// handle feel like it is lagging behind the pointer.
|
||||
container.style.width = getRenderedSidebarWidthCssValue(isOpen, nextWidth, renderedExtraWidth)
|
||||
container.style.width = getRenderedSidebarWidthCssValue(
|
||||
nextIsOpen,
|
||||
nextWidth,
|
||||
renderedExtraWidth
|
||||
)
|
||||
},
|
||||
[isOpen, renderedExtraWidth]
|
||||
[renderedOpen, renderedExtraWidth]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -94,6 +131,52 @@ export function useSidebarResize<T extends HTMLElement>({
|
||||
applyRenderedWidth(width)
|
||||
}, [applyRenderedWidth, width])
|
||||
|
||||
useEffect(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
if (openFrameRef.current !== null) {
|
||||
cancelAnimationFrame(openFrameRef.current)
|
||||
openFrameRef.current = null
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
setRenderedOpen(true)
|
||||
setContentAnimationState((current) => (current === 'open' ? current : 'opening'))
|
||||
// Why: opening reserves the layout width immediately so the terminal
|
||||
// resizes once, then animates only the sidebar's inner content. This
|
||||
// avoids the terminal blanking seen during continuous shell-width
|
||||
// animation while still giving the sidebar a smooth visual transition.
|
||||
openFrameRef.current = window.requestAnimationFrame(() => {
|
||||
openFrameRef.current = null
|
||||
setContentAnimationState('open')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setContentAnimationState((current) => (current === 'closed' ? current : 'closing'))
|
||||
// Why: keep the sidebar's layout width alive during the exit animation so
|
||||
// the user sees content slide/fade away first, then release the space at
|
||||
// the end. The terminal only snaps once when the sidebar is fully gone.
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null
|
||||
setRenderedOpen(false)
|
||||
setContentAnimationState('closed')
|
||||
}, SIDEBAR_TOGGLE_ANIMATION_MS)
|
||||
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
if (openFrameRef.current !== null) {
|
||||
cancelAnimationFrame(openFrameRef.current)
|
||||
openFrameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const stopResize = useCallback(() => {
|
||||
if (!isResizingRef.current) {
|
||||
return
|
||||
@@ -162,6 +245,14 @@ export function useSidebarResize<T extends HTMLElement>({
|
||||
frameRef.current = null
|
||||
}
|
||||
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
if (openFrameRef.current !== null) {
|
||||
cancelAnimationFrame(openFrameRef.current)
|
||||
openFrameRef.current = null
|
||||
}
|
||||
isResizingRef.current = false
|
||||
resetDocumentStyles()
|
||||
}
|
||||
@@ -181,5 +272,5 @@ export function useSidebarResize<T extends HTMLElement>({
|
||||
[width]
|
||||
)
|
||||
|
||||
return { containerRef, isResizing, onResizeStart }
|
||||
return { containerRef, isResizing, onResizeStart, renderedOpen, contentAnimationState }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user