@@ -988,108 +988,103 @@ function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
988988
989989// --- Kill animation ---
990990//
991- // Orchestrates the visual reclaim when a pane is killed. Captures pre-rects of
992- // every surviving pane's group element, removes the panel (dockview snaps the
993- // layout), then:
994- // - Renders a ghost overlay at the killed pane's position and fades it out.
995- // - Applies a FLIP-style clip-path reveal on each grower so its newly claimed
996- // territory is hidden at start and swept in by the animation. We use
997- // clip-path (not transform) because transform would corrupt the grower's
998- // getBoundingClientRect and make SelectionOverlay lag.
991+ // Orchestrates the visual reclaim when a pane is killed:
992+ // 1. Fade the real killed pane's group element in place (its actual content
993+ // dissolves — a solid-color ghost over a same-colored background would be
994+ // invisible).
995+ // 2. After the fade completes, capture pre-rects of surviving panes, remove
996+ // the panel (dockview snaps the layout), and FLIP each grower via
997+ // clip-path so its newly claimed territory is hidden at start and swept
998+ // in by the transition. clip-path (not transform) keeps
999+ // getBoundingClientRect accurate so the SelectionOverlay doesn't lag.
9991000//
1000- // For the "no surviving panes" case (last-pane kill), only the ghost fade runs;
1001- // the delayed auto-spawn (see onDidRemovePanel) creates a fresh pane after the
1002- // fade, tagged with 'top-left' spawn direction.
1003- function orchestrateKill ( api : DockviewApi , killedId : string ) : void {
1001+ // killInProgressRef is set across api.removePanel so the onDidRemovePanel
1002+ // auto-spawn handler knows we already waited for our own fade and can skip
1003+ // its own 440ms delay (avoids stacking 440ms + 440ms on last-pane kill).
1004+ function orchestrateKill (
1005+ api : DockviewApi ,
1006+ killedId : string ,
1007+ selectPanel : ( id : string ) => void ,
1008+ setSelectedId : ( id : string | null ) => void ,
1009+ killInProgressRef : { current : boolean } ,
1010+ ) : void {
10041011 const panel = api . getPanel ( killedId ) ;
10051012 if ( ! panel ) return ;
10061013
1007- // Respect reduced-motion: do the bare removal with no ghost / no FLIP so
1008- // reduced-motion users aren't left staring at a static surface-colored rect.
1009- const reduceMotion = typeof window !== 'undefined'
1010- && window . matchMedia ?.( '(prefers-reduced-motion: reduce)' ) . matches ;
1011- if ( reduceMotion ) {
1014+ const bareRemove = ( ) => {
1015+ killInProgressRef . current = true ;
10121016 destroyTerminal ( killedId ) ;
10131017 api . removePanel ( panel ) ;
1014- return ;
1015- }
1018+ killInProgressRef . current = false ;
1019+ if ( api . panels . length > 0 ) selectPanel ( api . panels [ 0 ] . id ) ;
1020+ else setSelectedId ( null ) ;
1021+ } ;
10161022
1023+ const reduceMotion = typeof window !== 'undefined'
1024+ && window . matchMedia ?.( '(prefers-reduced-motion: reduce)' ) . matches ;
10171025 const killedGroupEl = panel . api . group ?. element ;
1018- const killedRect = killedGroupEl ?. getBoundingClientRect ( ) ;
1019-
1020- // Capture pre-rects of every OTHER panel.
1021- const preRects = new Map < string , { el : HTMLElement ; rect : DOMRect } > ( ) ;
1022- for ( const p of api . panels ) {
1023- if ( p . id === killedId ) continue ;
1024- const el = p . api . group ?. element ;
1025- if ( el ) preRects . set ( p . id , { el, rect : el . getBoundingClientRect ( ) } ) ;
1026+ if ( reduceMotion || ! killedGroupEl ) {
1027+ bareRemove ( ) ;
1028+ return ;
10261029 }
10271030
1028- // Remove the panel. Dockview snaps the layout synchronously.
1029- destroyTerminal ( killedId ) ;
1030- api . removePanel ( panel ) ;
1031-
1032- // Collect growers (panes whose rect changed) for the FLIP reveal below.
1033- interface Grower { el : HTMLElement ; preRect : DOMRect ; postRect : DOMRect ; }
1034- const growers : Grower [ ] = [ ] ;
1035- for ( const p of api . panels ) {
1036- const pre = preRects . get ( p . id ) ;
1037- if ( ! pre ) continue ;
1038- const postRect = pre . el . getBoundingClientRect ( ) ;
1039- const dw = postRect . width - pre . rect . width ;
1040- const dh = postRect . height - pre . rect . height ;
1041- if ( Math . abs ( dw ) < 0.5 && Math . abs ( dh ) < 0.5 ) continue ;
1042- growers . push ( { el : pre . el , preRect : pre . rect , postRect } ) ;
1043- }
1031+ // Fade the killed pane in place. Block input on it during the fade.
1032+ killedGroupEl . style . pointerEvents = 'none' ;
1033+ killedGroupEl . classList . add ( 'pane-fading-out' ) ;
1034+
1035+ let finalized = false ;
1036+ const finalize = ( ) => {
1037+ if ( finalized ) return ;
1038+ finalized = true ;
1039+
1040+ // Snapshot pre-rects just before removal.
1041+ interface Pre { el : HTMLElement ; rect : DOMRect ; }
1042+ const preRects = new Map < string , Pre > ( ) ;
1043+ for ( const p of api . panels ) {
1044+ if ( p . id === killedId ) continue ;
1045+ const el = p . api . group ?. element ;
1046+ if ( el ) preRects . set ( p . id , { el, rect : el . getBoundingClientRect ( ) } ) ;
1047+ }
10441048
1045- // Mount ghost overlay at the killed pane's pre-removal rect and fade it out.
1046- if ( killedRect ) {
1047- const ghost = document . createElement ( 'div' ) ;
1048- Object . assign ( ghost . style , {
1049- position : 'fixed' ,
1050- top : `${ killedRect . top } px` ,
1051- left : `${ killedRect . left } px` ,
1052- width : `${ killedRect . width } px` ,
1053- height : `${ killedRect . height } px` ,
1054- background : 'var(--color-surface)' ,
1055- zIndex : '55' ,
1056- pointerEvents : 'none' ,
1057- } ) ;
1058- ghost . classList . add ( 'pane-fading-out' ) ;
1059- document . body . appendChild ( ghost ) ;
1060- const cleanup = ( ) => {
1061- if ( ghost . isConnected ) ghost . remove ( ) ;
1062- } ;
1063- ghost . addEventListener ( 'animationend' , cleanup , { once : true } ) ;
1064- // Safety: if the browser ever elides animationend (or the element is
1065- // detached early), a timeout ensures the ghost doesn't linger.
1066- setTimeout ( cleanup , 1000 ) ;
1067- }
1049+ bareRemove ( ) ;
1050+
1051+ // FLIP each grower.
1052+ for ( const p of api . panels ) {
1053+ const pre = preRects . get ( p . id ) ;
1054+ if ( ! pre ) continue ;
1055+ const postRect = pre . el . getBoundingClientRect ( ) ;
1056+ const dw = postRect . width - pre . rect . width ;
1057+ const dh = postRect . height - pre . rect . height ;
1058+ if ( Math . abs ( dw ) < 0.5 && Math . abs ( dh ) < 0.5 ) continue ;
1059+
1060+ // Clear any in-progress spawn animation before applying FLIP.
1061+ pre . el . classList . remove ( 'pane-spawning-from-left' , 'pane-spawning-from-top' , 'pane-spawning-from-top-left' ) ;
1062+
1063+ const clipTop = Math . max ( 0 , ( pre . rect . top - postRect . top ) / postRect . height * 100 ) ;
1064+ const clipBottom = Math . max ( 0 , ( postRect . bottom - pre . rect . bottom ) / postRect . height * 100 ) ;
1065+ const clipLeft = Math . max ( 0 , ( pre . rect . left - postRect . left ) / postRect . width * 100 ) ;
1066+ const clipRight = Math . max ( 0 , ( postRect . right - pre . rect . right ) / postRect . width * 100 ) ;
1067+
1068+ pre . el . style . transition = 'none' ;
1069+ pre . el . style . clipPath = `inset(${ clipTop } % ${ clipRight } % ${ clipBottom } % ${ clipLeft } %)` ;
1070+ void pre . el . offsetHeight ;
1071+ pre . el . style . transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)' ;
1072+ pre . el . style . clipPath = 'inset(0)' ;
1073+ const cleanup = ( ) => {
1074+ pre . el . style . transition = '' ;
1075+ pre . el . style . clipPath = '' ;
1076+ } ;
1077+ pre . el . addEventListener ( 'transitionend' , cleanup , { once : true } ) ;
1078+ setTimeout ( cleanup , 1000 ) ;
1079+ }
1080+ } ;
10681081
1069- // FLIP each grower via clip-path: start with the new territory clipped away,
1070- // transition to no clip so the territory sweeps in.
1071- for ( const { el, preRect, postRect } of growers ) {
1072- // Clear any in-progress spawn animation on this grower before applying FLIP.
1073- el . classList . remove ( 'pane-spawning-from-left' , 'pane-spawning-from-top' , 'pane-spawning-from-top-left' ) ;
1074-
1075- const clipTop = Math . max ( 0 , ( preRect . top - postRect . top ) / postRect . height * 100 ) ;
1076- const clipBottom = Math . max ( 0 , ( postRect . bottom - preRect . bottom ) / postRect . height * 100 ) ;
1077- const clipLeft = Math . max ( 0 , ( preRect . left - postRect . left ) / postRect . width * 100 ) ;
1078- const clipRight = Math . max ( 0 , ( postRect . right - preRect . right ) / postRect . width * 100 ) ;
1079-
1080- el . style . transition = 'none' ;
1081- el . style . clipPath = `inset(${ clipTop } % ${ clipRight } % ${ clipBottom } % ${ clipLeft } %)` ;
1082- // Force reflow so the starting clip is committed before we transition.
1083- void el . offsetHeight ;
1084- el . style . transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)' ;
1085- el . style . clipPath = 'inset(0)' ;
1086- const cleanup = ( ) => {
1087- el . style . transition = '' ;
1088- el . style . clipPath = '' ;
1089- } ;
1090- el . addEventListener ( 'transitionend' , cleanup , { once : true } ) ;
1091- setTimeout ( cleanup , 1000 ) ;
1092- }
1082+ killedGroupEl . addEventListener ( 'animationend' , ( ev ) => {
1083+ if ( ( ev as AnimationEvent ) . animationName !== 'pane-fade-out' ) return ;
1084+ finalize ( ) ;
1085+ } ) ;
1086+ // Safety: if animationend never fires, still finalize.
1087+ setTimeout ( finalize , 1000 ) ;
10931088}
10941089
10951090
@@ -1129,6 +1124,11 @@ export function Pond({
11291124 // matching directional entrance animation.
11301125 const freshlySpawnedRef = useRef ( new Map < string , SpawnDirection > ( ) ) ;
11311126
1127+ // True only across the api.removePanel() call inside orchestrateKill. Lets
1128+ // onDidRemovePanel know the kill path already paid the animation delay (via
1129+ // the in-place fade) so the auto-spawn shouldn't re-delay another 440ms.
1130+ const killInProgressRef = useRef ( false ) ;
1131+
11321132 // Consumed once in handleReady to restore existing sessions
11331133 const initialPaneIdsRef = useRef ( initialPaneIds ) ;
11341134 const restoredLayoutRef = useRef ( restoredLayout ) ;
@@ -1465,7 +1465,8 @@ export function Pond({
14651465 if ( e . api . totalPanels !== 0 ) return ;
14661466 const reduceMotion = typeof window !== 'undefined'
14671467 && window . matchMedia ?.( '(prefers-reduced-motion: reduce)' ) . matches ;
1468- const delay = reduceMotion ? 0 : 440 ;
1468+ // Kill path already waited during the in-place fade; no extra delay.
1469+ const delay = ( reduceMotion || killInProgressRef . current ) ? 0 : 440 ;
14691470 const spawn = ( ) => {
14701471 if ( e . api . totalPanels > 0 ) return ;
14711472 const id = generatePaneId ( ) ;
@@ -1585,16 +1586,12 @@ export function Pond({
15851586 return ;
15861587 }
15871588 if ( e . key . toLowerCase ( ) === ck . char . toLowerCase ( ) ) {
1588- // Dismiss the modal first so it doesn't hover over the kill animation,
1589- // then orchestrate the ghost-crush + FLIP reveal on survivors.
1589+ // Dismiss the modal first so it doesn't hover over the kill animation.
1590+ // orchestrateKill fades the pane in place, then removes + FLIPs the
1591+ // survivors, handling selection updates itself (since the fade is
1592+ // async and selection must wait until the actual removal fires).
15901593 setConfirmKill ( null ) ;
1591- orchestrateKill ( api , ck . id ) ;
1592- // Select next panel (or null while the auto-spawn is about to fire).
1593- if ( api . panels . length > 0 ) {
1594- selectPanel ( api . panels [ 0 ] . id ) ;
1595- } else {
1596- setSelectedId ( null ) ;
1597- }
1594+ orchestrateKill ( api , ck . id , selectPanel , setSelectedId , killInProgressRef ) ;
15981595 return ;
15991596 }
16001597 // Wrong key — shake then dismiss
0 commit comments