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/logic/panel-keys.test.ts | 73 +++++++++++++ src/features/layout/logic/panel-keys.ts | 118 +++++++++++++++++++++ src/features/layout/ui/TilePanels.svelte | 148 +++++++++++++++++++++------ src/features/layout/ui/TileRoot.test.ts | 53 +++++++++- src/features/layout/ui/panel-motion.ts | 83 +++++++++++++++ 5 files changed, 443 insertions(+), 32 deletions(-) create mode 100644 src/features/layout/logic/panel-keys.test.ts create mode 100644 src/features/layout/logic/panel-keys.ts create mode 100644 src/features/layout/ui/panel-motion.ts (limited to 'src/features/layout') diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts new file mode 100644 index 0000000..b45671a --- /dev/null +++ b/src/features/layout/logic/panel-keys.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys"; + +describe("initialPanelKeys", () => { + it("numbers the panels in order and seeds past them", () => { + expect(initialPanelKeys(["tabs", "model"])).toEqual({ + views: ["tabs", "model"], + keys: [0, 1], + seed: 2, + }); + }); +}); + +describe("reconcilePanelKeys", () => { + const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2 + + it("closing the MIDDLE panel drops the middle key (not the last)", () => { + const next = reconcilePanelKeys(start, ["tabs", "usage"]); + expect(next.keys).toEqual([0, 2]); + expect(next.seed).toBe(3); + }); + + it("adding a panel gives only the new one a fresh key", () => { + const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]); + expect(next.keys).toEqual([0, 1, 2, 3]); + expect(next.seed).toBe(4); + }); + + it("a dropdown swap keeps every key positional", () => { + const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]); + expect(next.keys).toEqual([0, 1, 2]); + }); + + it("handles duplicate views and never reuses a key", () => { + const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2 + const removed = reconcilePanelKeys(dup, ["a", "a"]); + expect(removed.keys).toEqual([0, 2]); + const added = reconcilePanelKeys(removed, ["a", "a", "a"]); + expect(added.keys).toEqual([0, 2, 3]); + expect(new Set(added.keys).size).toBe(3); + }); + + it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => { + const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2 + expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint + expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]); + }); + + it("ignores a stale hint that does not describe the change", () => { + // The tree changed some other way (e.g. a remote edit) — fall back to LCS. + expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]); + expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]); + }); + + it("an unrelated replacement of different length gets all-fresh keys", () => { + const next = reconcilePanelKeys(start, ["x"]); + expect(next.keys).toEqual([3]); + }); +}); + +describe("swappedPanels", () => { + it("reports a surviving panel whose view changed", () => { + const prev = initialPanelKeys(["tabs", "model"]); + const next = reconcilePanelKeys(prev, ["tabs", "usage"]); + expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]); + }); + + it("does not report adds or removes", () => { + const prev = initialPanelKeys(["tabs", "model", "usage"]); + expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0); + expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0); + }); +}); diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts new file mode 100644 index 0000000..76a053f --- /dev/null +++ b/src/features/layout/logic/panel-keys.ts @@ -0,0 +1,118 @@ +/** + * Stable panel keys — the PURE identity scheme behind the panel height + * animation. + * + * Panel leaves carry no required id, so the panels list can't be keyed by the + * tree. Keying by INDEX is wrong for removal: closing the middle of three + * panels would re-use the middle DOM node for the third view and animate the + * LAST panel out. This reconciles a stable numeric key per panel across tree + * updates, from the view ids alone: + * + * - same length → keys stay positional (a dropdown SWAP keeps its panel — + * the panel morphs its height to the new view instead of leaving). + * - length changed → the longest common subsequence of view ids keeps its + * keys; every unmatched new panel gets a fresh key (an ADD), every + * unmatched old key disappears (a REMOVE). + * + * Zero Svelte, zero DOM. + */ + +export interface PanelKeys { + /** View id per panel, in order. */ + readonly views: readonly string[]; + /** Stable key per panel, aligned with `views`. */ + readonly keys: readonly number[]; + /** The next fresh key. */ + readonly seed: number; +} + +/** Keys for a freshly mounted panels list. */ +export function initialPanelKeys(views: readonly string[]): PanelKeys { + return { views: [...views], keys: views.map((_, i) => i), seed: views.length }; +} + +/** + * Reconcile `prev` against the next view list (see the module doc). + * + * `removedIndex` is an optional HINT from a local ✕ click: when the next list + * is exactly `prev` minus that index, that exact panel is the one removed. + * Without it, closing one of two identical adjacent views is ambiguous to the + * subsequence match (it would drop the later one). + */ +export function reconcilePanelKeys( + prev: PanelKeys, + nextViews: readonly string[], + removedIndex?: number, +): PanelKeys { + if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) { + return { + views: [...nextViews], + keys: prev.keys.filter((_, i) => i !== removedIndex), + seed: prev.seed, + }; + } + if (nextViews.length === prev.views.length) { + return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed }; + } + const matched = lcsMatch(prev.views, nextViews); + let seed = prev.seed; + const keys = nextViews.map((_, j) => { + const i = matched.get(j); + if (i !== undefined) return prev.keys[i] ?? seed++; + return seed++; + }); + return { views: [...nextViews], keys, seed }; +} + +/** + * The views whose panel SURVIVED but now mounts a different view (a swap): + * key → { from, to }. Adds/removes are not swaps. + */ +export function swappedPanels( + prev: PanelKeys, + next: PanelKeys, +): Map { + const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""])); + const out = new Map(); + next.keys.forEach((k, i) => { + const from = before.get(k); + const to = next.views[i] ?? ""; + if (from !== undefined && from !== to) out.set(k, { from, to }); + }); + return out; +} + +function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean { + if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false; + return next.every((v, j) => v === prev[j < index ? j : j + 1]); +} + +/** LCS alignment: next index → prev index for every matched element. */ +function lcsMatch(a: readonly string[], b: readonly string[]): Map { + const n = a.length; + const m = b.length; + // dp[i][j] = LCS length of a[i..] and b[j..] + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + const row = dp[i] as number[]; + const below = dp[i + 1] as number[]; + row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0); + } + } + const out = new Map(); + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.set(j, i); + i++; + j++; + } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) { + i++; + } else { + j++; + } + } + return out; +} 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. -->