summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/logic/panel-keys.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout/logic/panel-keys.ts')
-rw-r--r--src/features/layout/logic/panel-keys.ts118
1 files changed, 118 insertions, 0 deletions
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<number, { from: string; to: string }> {
+ const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""]));
+ const out = new Map<number, { from: string; to: string }>();
+ 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<number, number> {
+ 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<number>(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<number, number>();
+ 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;
+}