1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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;
}
|