Skip to content

Commit a08d415

Browse files
nedtwiggclaude
andcommitted
Kill animation: fade the real pane in place (visible fade)
A same-color ghost over a same-color background had nothing to fade against — the previous fade was effectively invisible. Fade the actual killed pane's group element instead, so the real header/terminal content dissolves. Then finalize removal + FLIP the growers after animationend. killInProgressRef is set across api.removePanel so the onDidRemovePanel auto-spawn handler skips its own 440ms delay — the fade already consumed that time. Detach path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d890240 commit a08d415

File tree

1 file changed

+97
-100
lines changed

1 file changed

+97
-100
lines changed

lib/src/components/Pond.tsx

Lines changed: 97 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)