From bbf9728721bd25fc5b0532c16088e9e025dadb40 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 21 Oct 2026 21:06:12 +0900 Subject: feat(layout): GSAP height animation when sidebar views are added, closed or changed --- src/features/layout/ui/TilePanels.svelte | 148 ++++++++++++++++++++++++------- src/features/layout/ui/TileRoot.test.ts | 53 ++++++++++- src/features/layout/ui/panel-motion.ts | 83 +++++++++++++++++ 3 files changed, 252 insertions(+), 32 deletions(-) create mode 100644 src/features/layout/ui/panel-motion.ts (limited to 'src/features/layout/ui') diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte index 9162b00..cc5fa89 100644 --- a/src/features/layout/ui/TilePanels.svelte +++ b/src/features/layout/ui/TilePanels.svelte @@ -8,9 +8,17 @@ */ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract"; import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import { untrack } from "svelte"; import { viewRegistry } from "../logic/registry"; import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit"; + import { + initialPanelKeys, + type PanelKeys, + reconcilePanelKeys, + swappedPanels, + } from "../logic/panel-keys"; import { markerStyle } from "../logic/marker"; + import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion"; import TileLeaf from "./TileLeaf.svelte"; let { @@ -38,45 +46,122 @@ // appear in the dropdowns. Reactive so late registrations re-render options. const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible)); const addView = $derived(firstPanelView(panelViews)); + + // Stable per-panel keys (pure reconcile, memoised across tree updates) so a + // closed panel is the one that animates out, not whichever is last. + // A local ✕ click hints WHICH panel left (consumed by the next tree change; + // ignored if that change isn't exactly this removal, e.g. a remote edit). + let keyMemo: PanelKeys | null = null; + let removeHint: number | undefined; + const keyed = $derived.by(() => { + const views = node.panels.map((p) => p.view); + keyMemo = + keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint); + removeHint = undefined; + return keyMemo; + }); + + function removePanel(idx: number): void { + removeHint = idx; + onEdit([removePanelOp(path, idx)]); + } + + // View SWAP: the panel BOX (the well) animates from the old view's height + // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are + // the per-panel in:/out: transitions below.) + let columnEl = $state(); + /** The keys the DOM currently shows (set after each update). */ + let shownKeys: PanelKeys | null = null; + let before: { keys: PanelKeys; heights: Map } | null = null; + $effect.pre(() => { + const next = keyed; + untrack(() => { + const heights = new Map(); + for (const el of panelEls()) { + const well = wellOf(el); + if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight); + } + before = { keys: shownKeys ?? next, heights }; + }); + }); + $effect(() => { + const now = keyed; + untrack(() => { + const prev = before; + before = null; + shownKeys = now; + if (prev === null) return; + for (const key of swappedPanels(prev.keys, now).keys()) { + const el = panelEls().find((e) => Number(e.dataset.panelKey) === key); + const well = el === undefined ? null : wellOf(el); + const from = prev.heights.get(key); + if (well !== null && from !== undefined) morphPanelHeight(well, from); + } + }); + }); + + function wellOf(panel: HTMLElement): HTMLElement | null { + return panel.querySelector(":scope > [data-panel-well]"); + } + + function panelEls(): HTMLElement[] { + return columnEl === undefined + ? [] + : [...columnEl.querySelectorAll(":scope > [data-panel-key]")]; + } +
- {#each node.panels as panel, idx (idx)} - -
-
- - {#if idx > 0} - + {/if} +
-
- +
+ +
{/each} @@ -85,8 +170,9 @@ + replaced; `shrink-0` so the column can never squash it. The last panel's + `pb-2` is the gap above it; `mb-2` = the same gap below, so it + doesn't sit tight to the end. -->