diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 21:06:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 21:06:12 +0900 |
| commit | bbf9728721bd25fc5b0532c16088e9e025dadb40 (patch) | |
| tree | a57e270a79d8fbd8b5454af60f35f62a4f70b8d0 /src/features/layout/ui/TilePanels.svelte | |
| parent | 98f0494e46d09aa7799fa3018705d39ae04ea4eb (diff) | |
| download | dispatch-web-bbf9728721bd25fc5b0532c16088e9e025dadb40.tar.gz dispatch-web-bbf9728721bd25fc5b0532c16088e9e025dadb40.zip | |
feat(layout): GSAP height animation when sidebar views are added, closed or changed
Diffstat (limited to 'src/features/layout/ui/TilePanels.svelte')
| -rw-r--r-- | src/features/layout/ui/TilePanels.svelte | 148 |
1 files changed, 117 insertions, 31 deletions
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<HTMLDivElement | undefined>(); + /** The keys the DOM currently shows (set after each update). */ + let shownKeys: PanelKeys | null = null; + let before: { keys: PanelKeys; heights: Map<number, number> } | null = null; + $effect.pre(() => { + const next = keyed; + untrack(() => { + const heights = new Map<number, number>(); + 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<HTMLElement>(":scope > [data-panel-well]"); + } + + function panelEls(): HTMLElement[] { + return columnEl === undefined + ? [] + : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")]; + } </script> <!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its natural height — if it shrank to the scroller (min-h-0 in a flex column), its children overflowed it and the trailing margin was never scrollable. --> +<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so + it grows/shrinks WITH the panel's height animation (a flex gap would jump). --> <div - class="flex min-h-0 shrink-0 flex-col gap-2" + bind:this={columnEl} + class="flex min-h-0 shrink-0 flex-col" style={renderMarker ? markerStyle(node.marker) : undefined} + data-panels > - {#each node.panels as panel, idx (idx)} - <!-- `.inset-well` (app.css): recessed into the sidebar, like the chat's - thinking/tool blocks. --> - <div class="inset-well flex flex-col p-3"> - <div class="flex items-center gap-1"> - <select - class="select select-bordered select-sm flex-1" - aria-label="Select a view" - value={panel.view} - onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} - > - <option value="" disabled>Select a view</option> - {#each panelViews as view (view.id)} - <option value={view.id}>{view.label}</option> - {/each} - </select> - {#if idx > 0} - <button - type="button" - class="btn btn-square btn-ghost btn-sm shrink-0" - aria-label="Remove view" - onclick={() => onEdit([removePanelOp(path, idx)])} + {#each node.panels as panel, idx (keyed.keys[idx])} + <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap, + so panels below slide smoothly); a view swap animates the WELL's + height. Either way only a height is tweened (clipped) — the contents + keep their natural size. `.inset-well` (app.css): recessed into the + sidebar, like the chat's thinking/tool blocks. --> + <div + class="shrink-0 pb-2" + data-panel-key={keyed.keys[idx]} + in:panelGrowIn + out:panelShrinkOut + > + <div class="inset-well flex flex-col p-3" data-panel-well> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.view} + onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} > - ✕ - </button> - {/if} - </div> + <option value="" disabled>Select a view</option> + {#each panelViews as view (view.id)} + <option value={view.id}>{view.label}</option> + {/each} + </select> + {#if idx > 0} + <button + type="button" + class="btn btn-square btn-ghost btn-sm shrink-0" + aria-label="Remove view" + onclick={() => removePanel(idx)} + > + ✕ + </button> + {/if} + </div> - <div class="mt-2"> - <TileLeaf node={panel} {ports} /> + <div class="mt-2"> + <TileLeaf node={panel} {ports} /> + </div> </div> </div> {/each} @@ -85,8 +170,9 @@ <!-- `.raised-btn` (app.css): the mirror of the view wells above it — pops out where they sink in; pressed, it sinks in like them. Height = DaisyUI's default button size (`--size-field` × 10), as the `btn` it - replaced; `shrink-0` so the column can never squash it. `mb-2` = the - gap between views (`gap-2`), so it doesn't sit tight to the end. --> + 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. --> <button type="button" class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0" |
