Skip to content

Commit e665bf4

Browse files
nedtwiggclaude
andcommitted
Kill animation + directional spawn
On kill: ghost overlay crushes from the pane's original rect toward the replacer (edge/centerline/bottom-right), and surviving panes FLIP-reveal their newly claimed territory via clip-path. Case detection is rect-based (measure pre/post bounds and classify by which sides grew), so it handles 2-pane, 3+ linear, and nested splits uniformly. Reduced-motion skips the animation entirely. Spawn animation is now directional: - horizontal split → reveal from left edge - vertical split → reveal from top edge - auto-spawn after last-pane kill → reveal from top-left corner Also fixes a one-frame flash where KillConfirmOverlay rendered its viewport-center fallback before the useEffect measured the panel (switched to useLayoutEffect so the rect lands before first paint). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7683690 commit e665bf4

2 files changed

Lines changed: 215 additions & 32 deletions

File tree

lib/src/components/Pond.tsx

Lines changed: 168 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,13 @@ export const RenamingIdContext = createContext<string | null>(null);
432432
export const ZoomedContext = createContext(false);
433433
export const WindowFocusedContext = createContext(true);
434434

435-
// Transient set of pane ids that were just created (split or empty-state auto-spawn).
436-
// TerminalPanel consumes (and removes) its id on first mount to trigger a spawn animation.
437-
export const FreshlySpawnedContext = createContext<Set<string>>(new Set());
435+
// Transient map of pane ids that were just created → their spawn direction.
436+
// TerminalPanel consumes (and removes) its id on first mount to trigger a directional spawn animation.
437+
// 'left' — born from horizontal split (new pane appeared to the right of the source)
438+
// 'top' — born from vertical split (new pane appeared below the source)
439+
// 'top-left' — auto-spawned after last-pane kill (diagonal counterpoint to the killed pane's crush to bottom-right)
440+
export type SpawnDirection = 'left' | 'top' | 'top-left';
441+
export const FreshlySpawnedContext = createContext<Map<string, SpawnDirection>>(new Map());
438442

439443
const ARROW_OPPOSITES: Record<string, string> = {
440444
ArrowLeft: 'ArrowRight', ArrowRight: 'ArrowLeft',
@@ -477,26 +481,29 @@ function TerminalPanel({ api }: IDockviewPanelProps) {
477481
};
478482
}, [api.id, panelElements, bumpVersion]);
479483

480-
// Freshly spawned: animate the whole dockview group (header + body) as one unit.
481-
// We target api.group.element instead of elRef so the tab header animates too.
482-
// Using clip-path (not transform) is deliberate — transforms would make
483-
// SelectionOverlay's getBoundingClientRect capture the scaled rect and lag
484-
// behind the pane until the animation ends.
484+
// Freshly spawned: animate the whole dockview group (header + body) as one unit
485+
// via a directional clip-path reveal. We target api.group.element instead of elRef
486+
// so the tab header animates too. clip-path (not transform) is deliberate —
487+
// transforms affect getBoundingClientRect, which would make the selection overlay
488+
// lag the pane until the animation ends.
485489
useLayoutEffect(() => {
486-
if (!freshlySpawned.has(api.id)) return;
490+
const direction = freshlySpawned.get(api.id);
491+
if (!direction) return;
487492
freshlySpawned.delete(api.id);
488493
const groupEl = api.group?.element;
489494
if (!groupEl) return;
490-
groupEl.classList.add('pane-spawning');
495+
const className = `pane-spawning-from-${direction}`;
496+
const animationName = `pane-spawn-from-${direction}`;
497+
groupEl.classList.add(className);
491498
const onEnd = (ev: AnimationEvent) => {
492-
if (ev.animationName !== 'pane-spawn') return;
493-
groupEl.classList.remove('pane-spawning');
499+
if (ev.animationName !== animationName) return;
500+
groupEl.classList.remove(className);
494501
groupEl.removeEventListener('animationend', onEnd);
495502
};
496503
groupEl.addEventListener('animationend', onEnd);
497504
return () => {
498505
groupEl.removeEventListener('animationend', onEnd);
499-
groupEl.classList.remove('pane-spawning');
506+
groupEl.classList.remove(className);
500507
};
501508
}, [api, freshlySpawned]);
502509

@@ -940,7 +947,10 @@ function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
940947
}) {
941948
const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null);
942949

943-
useEffect(() => {
950+
// useLayoutEffect (not useEffect) so the initial measurement + re-render happens
951+
// before the browser paints. Otherwise the centered-in-viewport fallback below
952+
// flashes for one frame before the overlay snaps to the panel.
953+
useLayoutEffect(() => {
944954
const panelEl = resolvePanelElement(panelElements.get(confirmKill.id));
945955
if (!panelEl) { setRect(null); return; }
946956

@@ -976,6 +986,137 @@ function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
976986
}
977987

978988

989+
// --- Kill animation ---
990+
//
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 with a clip-path
995+
// crush animation (direction chosen from how the layout actually changed).
996+
// - Applies a FLIP-style clip-path reveal on each grower so its newly claimed
997+
// territory is hidden at start and swept in by the animation. We use
998+
// clip-path (not transform) because transform would corrupt the grower's
999+
// getBoundingClientRect and make SelectionOverlay lag.
1000+
//
1001+
// For the "no surviving panes" case (Case C), we only render the ghost crushing
1002+
// to the bottom-right corner; the auto-spawn handler (onDidRemovePanel) will
1003+
// create a fresh pane tagged with 'top-left' spawn direction.
1004+
function orchestrateKill(api: DockviewApi, killedId: string): void {
1005+
const panel = api.getPanel(killedId);
1006+
if (!panel) return;
1007+
1008+
// Respect reduced-motion: do the bare removal with no ghost / no FLIP so
1009+
// reduced-motion users aren't left staring at a static surface-colored rect.
1010+
const reduceMotion = typeof window !== 'undefined'
1011+
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
1012+
if (reduceMotion) {
1013+
destroyTerminal(killedId);
1014+
api.removePanel(panel);
1015+
return;
1016+
}
1017+
1018+
const killedGroupEl = panel.api.group?.element;
1019+
const killedRect = killedGroupEl?.getBoundingClientRect();
1020+
1021+
// Capture pre-rects of every OTHER panel.
1022+
const preRects = new Map<string, { el: HTMLElement; rect: DOMRect }>();
1023+
for (const p of api.panels) {
1024+
if (p.id === killedId) continue;
1025+
const el = p.api.group?.element;
1026+
if (el) preRects.set(p.id, { el, rect: el.getBoundingClientRect() });
1027+
}
1028+
1029+
// Remove the panel. Dockview snaps the layout synchronously.
1030+
destroyTerminal(killedId);
1031+
api.removePanel(panel);
1032+
1033+
// Classify growers (panes whose rect changed) and pick the crush direction.
1034+
interface Grower { el: HTMLElement; preRect: DOMRect; postRect: DOMRect; }
1035+
const growers: Grower[] = [];
1036+
for (const p of api.panels) {
1037+
const pre = preRects.get(p.id);
1038+
if (!pre) continue;
1039+
const postRect = pre.el.getBoundingClientRect();
1040+
const dw = postRect.width - pre.rect.width;
1041+
const dh = postRect.height - pre.rect.height;
1042+
if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue;
1043+
growers.push({ el: pre.el, preRect: pre.rect, postRect });
1044+
}
1045+
1046+
let crushClass = 'pane-crushing-to-br';
1047+
if (killedRect && growers.length > 0) {
1048+
const horizontal = growers.some(g =>
1049+
Math.abs(g.postRect.width - g.preRect.width) > Math.abs(g.postRect.height - g.preRect.height)
1050+
);
1051+
const killedCenterX = (killedRect.left + killedRect.right) / 2;
1052+
const killedCenterY = (killedRect.top + killedRect.bottom) / 2;
1053+
if (horizontal) {
1054+
const hasLeft = growers.some(g => (g.postRect.left + g.postRect.right) / 2 < killedCenterX);
1055+
const hasRight = growers.some(g => (g.postRect.left + g.postRect.right) / 2 > killedCenterX);
1056+
if (hasLeft && hasRight) crushClass = 'pane-crushing-to-hcenter';
1057+
else if (hasLeft) crushClass = 'pane-crushing-to-right';
1058+
else crushClass = 'pane-crushing-to-left';
1059+
} else {
1060+
const hasAbove = growers.some(g => (g.postRect.top + g.postRect.bottom) / 2 < killedCenterY);
1061+
const hasBelow = growers.some(g => (g.postRect.top + g.postRect.bottom) / 2 > killedCenterY);
1062+
if (hasAbove && hasBelow) crushClass = 'pane-crushing-to-vcenter';
1063+
else if (hasAbove) crushClass = 'pane-crushing-down';
1064+
else crushClass = 'pane-crushing-up';
1065+
}
1066+
}
1067+
1068+
// Mount ghost overlay at the killed pane's pre-removal rect.
1069+
if (killedRect) {
1070+
const ghost = document.createElement('div');
1071+
Object.assign(ghost.style, {
1072+
position: 'fixed',
1073+
top: `${killedRect.top}px`,
1074+
left: `${killedRect.left}px`,
1075+
width: `${killedRect.width}px`,
1076+
height: `${killedRect.height}px`,
1077+
background: 'var(--color-surface)',
1078+
zIndex: '55',
1079+
pointerEvents: 'none',
1080+
});
1081+
ghost.classList.add(crushClass);
1082+
document.body.appendChild(ghost);
1083+
const cleanup = () => {
1084+
if (ghost.isConnected) ghost.remove();
1085+
};
1086+
ghost.addEventListener('animationend', cleanup, { once: true });
1087+
// Safety: reduced-motion + forwards fill mode still fires animationend, but
1088+
// if the browser ever elides the event (or the element is detached early),
1089+
// a timeout ensures the ghost doesn't linger.
1090+
setTimeout(cleanup, 1000);
1091+
}
1092+
1093+
// FLIP each grower via clip-path: start with the new territory clipped away,
1094+
// transition to no clip so the territory sweeps in.
1095+
for (const { el, preRect, postRect } of growers) {
1096+
// Clear any in-progress spawn animation on this grower before applying FLIP.
1097+
el.classList.remove('pane-spawning-from-left', 'pane-spawning-from-top', 'pane-spawning-from-top-left');
1098+
1099+
const clipTop = Math.max(0, (preRect.top - postRect.top) / postRect.height * 100);
1100+
const clipBottom = Math.max(0, (postRect.bottom - preRect.bottom) / postRect.height * 100);
1101+
const clipLeft = Math.max(0, (preRect.left - postRect.left) / postRect.width * 100);
1102+
const clipRight = Math.max(0, (postRect.right - preRect.right) / postRect.width * 100);
1103+
1104+
el.style.transition = 'none';
1105+
el.style.clipPath = `inset(${clipTop}% ${clipRight}% ${clipBottom}% ${clipLeft}%)`;
1106+
// Force reflow so the starting clip is committed before we transition.
1107+
void el.offsetHeight;
1108+
el.style.transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)';
1109+
el.style.clipPath = 'inset(0)';
1110+
const cleanup = () => {
1111+
el.style.transition = '';
1112+
el.style.clipPath = '';
1113+
};
1114+
el.addEventListener('transitionend', cleanup, { once: true });
1115+
setTimeout(cleanup, 1000);
1116+
}
1117+
}
1118+
1119+
9791120
// --- Main component ---
9801121

9811122
export function Pond({
@@ -1007,9 +1148,10 @@ export function Pond({
10071148
// Nothing reads this yet; it exists so future work can copy cwd / terminal kind from the source.
10081149
const paneToCopyByIdRef = useRef(new Map<string, string>());
10091150

1010-
// Ids of panes that were just spawned (split or empty-state auto-spawn). TerminalPanel
1011-
// consumes its id on first mount to play an entrance animation.
1012-
const freshlySpawnedRef = useRef(new Set<string>());
1151+
// Ids of panes that were just spawned, keyed by id with the direction the spawn
1152+
// should reveal from. TerminalPanel consumes its id on first mount to play the
1153+
// matching directional entrance animation.
1154+
const freshlySpawnedRef = useRef(new Map<string, SpawnDirection>());
10131155

10141156
// Consumed once in handleReady to restore existing sessions
10151157
const initialPaneIdsRef = useRef(initialPaneIds);
@@ -1336,7 +1478,7 @@ export function Pond({
13361478
if (e.api.totalPanels === 0) {
13371479
const id = generatePaneId();
13381480
paneToCopyByIdRef.current.set(id, removed.id);
1339-
freshlySpawnedRef.current.add(id);
1481+
freshlySpawnedRef.current.set(id, 'top-left');
13401482
e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '<unnamed>' });
13411483
selectPanel(id);
13421484
}
@@ -1444,18 +1586,16 @@ export function Pond({
14441586
return;
14451587
}
14461588
if (e.key.toLowerCase() === ck.char.toLowerCase()) {
1447-
const panel = api.getPanel(ck.id);
1448-
if (panel) {
1449-
destroyTerminal(ck.id);
1450-
api.removePanel(panel);
1451-
}
1452-
// Select next panel
1589+
// Dismiss the modal first so it doesn't hover over the kill animation,
1590+
// then orchestrate the ghost-crush + FLIP reveal on survivors.
1591+
setConfirmKill(null);
1592+
orchestrateKill(api, ck.id);
1593+
// Select next panel (or null while the auto-spawn is about to fire).
14531594
if (api.panels.length > 0) {
14541595
selectPanel(api.panels[0].id);
14551596
} else {
14561597
setSelectedId(null);
14571598
}
1458-
setConfirmKill(null);
14591599
return;
14601600
}
14611601
// Wrong key — shake then dismiss
@@ -1782,7 +1922,9 @@ export function Pond({
17821922
const newId = generatePaneId();
17831923
const ref = id && api.getPanel(id) ? id : null;
17841924
if (ref) paneToCopyByIdRef.current.set(newId, ref);
1785-
freshlySpawnedRef.current.add(newId);
1925+
// Horizontal split places the new pane to the right → reveal from its left edge.
1926+
// Vertical split places it below → reveal from its top edge.
1927+
freshlySpawnedRef.current.set(newId, direction === 'right' ? 'left' : 'top');
17861928
api.addPanel({
17871929
id: newId,
17881930
component: 'terminal',

lib/src/theme.css

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,56 @@ body.vscode-light {
146146
80% { translate: 2px; }
147147
}
148148

149-
@keyframes pane-spawn {
150-
from { opacity: 0; clip-path: inset(0 9% 18% 9%); }
149+
/* Pane spawn — directional clip-path reveal applied to the dockview group element.
150+
* clip-path (not transform) keeps getBoundingClientRect accurate so the selection
151+
* overlay measures the real post-animation bounds during the animation. */
152+
153+
@keyframes pane-spawn-from-left {
154+
from { opacity: 0; clip-path: inset(0 100% 0 0); }
151155
to { opacity: 1; clip-path: inset(0 0 0 0); }
152156
}
153-
154-
.pane-spawning {
155-
animation: pane-spawn 440ms cubic-bezier(0.22, 1, 0.36, 1);
157+
@keyframes pane-spawn-from-top {
158+
from { opacity: 0; clip-path: inset(0 0 100% 0); }
159+
to { opacity: 1; clip-path: inset(0 0 0 0); }
156160
}
161+
@keyframes pane-spawn-from-top-left {
162+
from { opacity: 0; clip-path: inset(0 100% 100% 0); }
163+
to { opacity: 1; clip-path: inset(0 0 0 0); }
164+
}
165+
166+
.pane-spawning-from-left { animation: pane-spawn-from-left 440ms cubic-bezier(0.22, 1, 0.36, 1); }
167+
.pane-spawning-from-top { animation: pane-spawn-from-top 440ms cubic-bezier(0.22, 1, 0.36, 1); }
168+
.pane-spawning-from-top-left { animation: pane-spawn-from-top-left 440ms cubic-bezier(0.22, 1, 0.36, 1); }
169+
170+
/* Pane kill — crush the ghost overlay in the direction of the replacing neighbor
171+
* (or to the centerline for a middle kill, or to the bottom-right corner when no
172+
* replacer exists and the auto-spawn takes over). */
173+
174+
@keyframes pane-crush-to-left { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 100% 0 0); } }
175+
@keyframes pane-crush-to-right { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 0 100%); } }
176+
@keyframes pane-crush-up { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 100% 0); } }
177+
@keyframes pane-crush-down { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(100% 0 0 0); } }
178+
@keyframes pane-crush-to-hcenter { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 50% 0 50%); } }
179+
@keyframes pane-crush-to-vcenter { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(50% 0 50% 0); } }
180+
@keyframes pane-crush-to-br { from { clip-path: inset(0 0 0 0); opacity: 1; } to { clip-path: inset(100% 0 0 100%); opacity: 0; } }
181+
182+
.pane-crushing-to-left { animation: pane-crush-to-left 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
183+
.pane-crushing-to-right { animation: pane-crush-to-right 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
184+
.pane-crushing-up { animation: pane-crush-up 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
185+
.pane-crushing-down { animation: pane-crush-down 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
186+
.pane-crushing-to-hcenter { animation: pane-crush-to-hcenter 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
187+
.pane-crushing-to-vcenter { animation: pane-crush-to-vcenter 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
188+
.pane-crushing-to-br { animation: pane-crush-to-br 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
157189

158190
@media (prefers-reduced-motion: reduce) {
159-
.pane-spawning { animation: none; }
191+
.pane-spawning-from-left,
192+
.pane-spawning-from-top,
193+
.pane-spawning-from-top-left,
194+
.pane-crushing-to-left,
195+
.pane-crushing-to-right,
196+
.pane-crushing-up,
197+
.pane-crushing-down,
198+
.pane-crushing-to-hcenter,
199+
.pane-crushing-to-vcenter,
200+
.pane-crushing-to-br { animation: none; }
160201
}

0 commit comments

Comments
 (0)