@hyperframes/studio 0.7.99 → 0.7.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BY5RLMBA.js → hyperframes-player-BxSY8bs4.js} +1 -1
- package/dist/assets/{index-CQ07_DLG.js → index-BlRPDw0A.js} +1 -1
- package/dist/assets/{index-DMSqmZM6.js → index-DF06yGPO.js} +200 -200
- package/dist/assets/{index-DeXLAktv.js → index-Dkz5RbFX.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +473 -359
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -2
- package/src/components/StudioHeader.test.ts +28 -0
- package/src/components/StudioHeader.tsx +21 -2
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/nle/NLEContext.tsx +16 -7
- package/src/components/nle/TimelineResizeDivider.tsx +4 -11
- package/src/components/sidebar/LeftSidebar.storage.test.ts +28 -0
- package/src/components/sidebar/LeftSidebar.tsx +18 -3
- package/src/contexts/PanelLayoutContext.tsx +6 -3
- package/src/hooks/gsapEditOutcome.ts +19 -1
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +47 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +31 -3
- package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
- package/src/hooks/gsapResizeIntercept.test.ts +2 -2
- package/src/hooks/gsapResizeIntercept.ts +37 -14
- package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
- package/src/hooks/gsapResizeSweep.test.ts +274 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
- package/src/hooks/useGsapAwareEditing.ts +13 -5
- package/src/hooks/useGsapTweenCache.ts +16 -19
- package/src/hooks/usePanelLayout.test.ts +117 -0
- package/src/hooks/usePanelLayout.ts +121 -39
- package/src/player/components/Player.test.ts +13 -0
- package/src/player/components/Player.tsx +7 -1
- package/src/utils/clipboard.ts +1 -1
- package/src/utils/fitPanels.test.ts +164 -0
- package/src/utils/fitPanels.ts +151 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* Every shape of animated element a composition can hand the resize, swept.
|
|
4
|
+
*
|
|
5
|
+
* Both faults this branch fixes were found one composition at a time, which is
|
|
6
|
+
* a bad way to find the third. So this drives the real intercept across the
|
|
7
|
+
* cross-product of what an element's tweens can look like and holds every run
|
|
8
|
+
* to the two rules that were broken:
|
|
9
|
+
*
|
|
10
|
+
* 1. Never address an animation the source does not have. Sending a stale id
|
|
11
|
+
* is what "animation not found" is, and it leaves the element unsavable.
|
|
12
|
+
* 2. Never leave a tween spanning two property groups. The parser classifies
|
|
13
|
+
* such a tween as neither, so it loses its group suffix and its id along
|
|
14
|
+
* with it, and every later edit has nothing to address.
|
|
15
|
+
*
|
|
16
|
+
* The server stand-in answers the way the real one does — an id it cannot find
|
|
17
|
+
* is a rejection — and applies what it is told, so a run that corrupts the
|
|
18
|
+
* animation list is caught by the next mutation in the same run rather than by
|
|
19
|
+
* a person noticing weeks later.
|
|
20
|
+
*/
|
|
21
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
22
|
+
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
23
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
24
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
25
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
26
|
+
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
31
|
+
document.body.innerHTML = "";
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
type Props = Record<string, number>;
|
|
35
|
+
|
|
36
|
+
function tween(id: string, properties: Props, duration: number): GsapAnimation {
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
targetSelector: "#el",
|
|
40
|
+
propertyGroup: classifyTweenPropertyGroup(properties),
|
|
41
|
+
method: "to",
|
|
42
|
+
properties,
|
|
43
|
+
position: 0,
|
|
44
|
+
resolvedStart: 0,
|
|
45
|
+
duration,
|
|
46
|
+
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
|
|
47
|
+
} as unknown as GsapAnimation;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The dimensions an element's animations actually vary across. */
|
|
51
|
+
const SCALE = {
|
|
52
|
+
none: null,
|
|
53
|
+
"instant hold": () => tween("#el-scale", { scale: 1.2 }, 0),
|
|
54
|
+
tween: () => tween("#el-scale", { scale: 1.2 }, 2),
|
|
55
|
+
longhands: () => tween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
|
|
56
|
+
} as const;
|
|
57
|
+
const SIZE = {
|
|
58
|
+
none: null,
|
|
59
|
+
"instant hold": () => tween("#el-size", { width: 300, height: 200 }, 0),
|
|
60
|
+
tween: () => tween("#el-size", { width: 300, height: 200 }, 2),
|
|
61
|
+
} as const;
|
|
62
|
+
const POSITION = {
|
|
63
|
+
none: null,
|
|
64
|
+
"static hold": () => tween("#el-position", { x: 40, y: 60 }, 0),
|
|
65
|
+
tween: () => tween("#el-position", { x: 40, y: 60 }, 2),
|
|
66
|
+
} as const;
|
|
67
|
+
const EXTRA = {
|
|
68
|
+
none: null,
|
|
69
|
+
// What a 3D card carries, and the shape that produced two same-group ids.
|
|
70
|
+
"3d and rotation": () => [
|
|
71
|
+
tween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
|
|
72
|
+
tween("#el-rotation", { rotation: 720 }, 0),
|
|
73
|
+
tween("#el-other-2", { z: 50 }, 0),
|
|
74
|
+
],
|
|
75
|
+
// A tween that already spans two groups, which the resize has to split.
|
|
76
|
+
"a mixed tween": () => [tween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
interface Recorded {
|
|
80
|
+
rejected: string[];
|
|
81
|
+
corrupted: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The source, as the server sees it: a list of animations, a rejection for an
|
|
86
|
+
* id that is not in it, and the effect of each mutation applied.
|
|
87
|
+
*/
|
|
88
|
+
function fakeSource(initial: GsapAnimation[]) {
|
|
89
|
+
let current = initial;
|
|
90
|
+
const recorded: Recorded = { rejected: [], corrupted: [] };
|
|
91
|
+
|
|
92
|
+
const mergeInto = (id: string, properties: Props) => {
|
|
93
|
+
current = current.map((animation) => {
|
|
94
|
+
if (animation.id !== id) return animation;
|
|
95
|
+
const before = classifyTweenPropertyGroup(animation.properties ?? {});
|
|
96
|
+
const merged = { ...(animation.properties ?? {}), ...properties };
|
|
97
|
+
const after = classifyTweenPropertyGroup(merged);
|
|
98
|
+
// Mixing is only a fault when the resize CAUSED it. A tween that already
|
|
99
|
+
// spanned two groups is an input, and splitting it is the point.
|
|
100
|
+
if (before !== undefined && after === undefined) recorded.corrupted.push(id);
|
|
101
|
+
return { ...animation, properties: merged, propertyGroup: after } as GsapAnimation;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const split = (id: string) => {
|
|
106
|
+
const target = current.find((animation) => animation.id === id);
|
|
107
|
+
if (!target) return;
|
|
108
|
+
const byGroup = new Map<string, Props>();
|
|
109
|
+
for (const [key, value] of Object.entries(target.properties ?? {})) {
|
|
110
|
+
const group = classifyTweenPropertyGroup({ [key]: value as number }) ?? "other";
|
|
111
|
+
byGroup.set(group, { ...(byGroup.get(group) ?? {}), [key]: value as number });
|
|
112
|
+
}
|
|
113
|
+
current = [
|
|
114
|
+
...current.filter((animation) => animation.id !== id),
|
|
115
|
+
...[...byGroup].map(([group, properties]) =>
|
|
116
|
+
tween(`#el-split-${group}`, properties, target.duration ?? 0),
|
|
117
|
+
),
|
|
118
|
+
];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const apply = (mutation: Record<string, unknown>, id: string | undefined) => {
|
|
122
|
+
const properties = (mutation.properties ?? {}) as Props;
|
|
123
|
+
if (mutation.type === "split-into-property-groups") return id && split(id);
|
|
124
|
+
if (!id) {
|
|
125
|
+
if (mutation.type === "add")
|
|
126
|
+
current = [...current, tween(`#el-added-${current.length}`, properties, 0)];
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
mergeInto(id, properties);
|
|
130
|
+
const framed = (mutation.keyframes as Array<{ properties: Props }> | undefined) ?? [];
|
|
131
|
+
for (const frame of framed) mergeInto(id, frame.properties);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const commitMutation = vi.fn(async (_selection: unknown, mutation: Record<string, unknown>) => {
|
|
135
|
+
const id = mutation.animationId as string | undefined;
|
|
136
|
+
if (id && !current.some((animation) => animation.id === id)) {
|
|
137
|
+
recorded.rejected.push(`${String(mutation.type)}:${id}`);
|
|
138
|
+
throw new Error("animation not found");
|
|
139
|
+
}
|
|
140
|
+
apply(mutation, id);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { commitMutation, recorded, animations: () => current };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type Dimension = Array<[string, () => GsapAnimation[]]>;
|
|
147
|
+
|
|
148
|
+
function dimension(
|
|
149
|
+
entries: Record<string, null | (() => GsapAnimation | GsapAnimation[])>,
|
|
150
|
+
): Dimension {
|
|
151
|
+
return Object.entries(entries).map(([name, make]) => [
|
|
152
|
+
name,
|
|
153
|
+
() => {
|
|
154
|
+
if (!make) return [];
|
|
155
|
+
const made = make();
|
|
156
|
+
return Array.isArray(made) ? made : [made];
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildCases() {
|
|
162
|
+
const dimensions = [dimension(SCALE), dimension(SIZE), dimension(POSITION), dimension(EXTRA)];
|
|
163
|
+
let combos: Array<Array<[string, () => GsapAnimation[]]>> = [[]];
|
|
164
|
+
for (const next of dimensions) {
|
|
165
|
+
combos = combos.flatMap((combo) => next.map((entry) => [...combo, entry]));
|
|
166
|
+
}
|
|
167
|
+
const labels = ["scale", "size", "position", "extra"];
|
|
168
|
+
return combos.map((combo) => ({
|
|
169
|
+
name: combo.map(([name], index) => `${labels[index]} ${name}`).join(" / "),
|
|
170
|
+
animations: combo.flatMap(([, make]) => make()),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const CASES = buildCases();
|
|
175
|
+
|
|
176
|
+
it(`sweeps ${CASES.length} animated shapes without a stale id or a mixed tween`, async () => {
|
|
177
|
+
const failures: string[] = [];
|
|
178
|
+
|
|
179
|
+
for (const testCase of CASES) {
|
|
180
|
+
document.body.innerHTML = "";
|
|
181
|
+
const el = document.createElement("div");
|
|
182
|
+
el.id = "el";
|
|
183
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
184
|
+
el.setAttribute("data-hf-studio-original-box-height", "408");
|
|
185
|
+
document.body.append(el);
|
|
186
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
187
|
+
|
|
188
|
+
const source = fakeSource(testCase.animations);
|
|
189
|
+
try {
|
|
190
|
+
await tryGsapResizeIntercept(
|
|
191
|
+
{ id: "el", selector: "#el", element: el } as DomEditSelection,
|
|
192
|
+
{ width: 326, height: 213 },
|
|
193
|
+
testCase.animations,
|
|
194
|
+
null,
|
|
195
|
+
source.commitMutation as never,
|
|
196
|
+
async () => source.animations(),
|
|
197
|
+
);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// A rejection is recorded below; anything else is worth reporting as-is.
|
|
200
|
+
if (!(error instanceof Error) || error.message !== "animation not found") {
|
|
201
|
+
failures.push(`${testCase.name} — threw ${String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (source.recorded.rejected.length > 0) {
|
|
206
|
+
failures.push(`${testCase.name} — stale id: ${source.recorded.rejected.join(", ")}`);
|
|
207
|
+
}
|
|
208
|
+
if (source.recorded.corrupted.length > 0) {
|
|
209
|
+
failures.push(`${testCase.name} — mixed tween: ${source.recorded.corrupted.join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
expect(failures).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Which tween a resize edits when the element has several in the same group.
|
|
218
|
+
*
|
|
219
|
+
* A composition animates the same property more than once — a scale-in early
|
|
220
|
+
* and a scale-out late — and the one the user means is the one under the
|
|
221
|
+
* playhead. Editing the wrong one changes a moment they are not looking at and
|
|
222
|
+
* leaves the moment they ARE looking at unchanged, which reads as "the resize
|
|
223
|
+
* did nothing".
|
|
224
|
+
*/
|
|
225
|
+
function scaleAt(id: string, position: number): GsapAnimation {
|
|
226
|
+
return {
|
|
227
|
+
...tween(id, { scale: 1 }, 2),
|
|
228
|
+
position,
|
|
229
|
+
resolvedStart: position,
|
|
230
|
+
keyframes: {
|
|
231
|
+
keyframes: [
|
|
232
|
+
{ percentage: 0, properties: { scale: 1 } },
|
|
233
|
+
{ percentage: 100, properties: { scale: 1.4 } },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
} as unknown as GsapAnimation;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const PLAYHEADS: Array<[number, string]> = [
|
|
240
|
+
[0.5, "#el-early"],
|
|
241
|
+
[1.9, "#el-early"],
|
|
242
|
+
[3, "#el-early"],
|
|
243
|
+
[3.1, "#el-late"],
|
|
244
|
+
[4.5, "#el-late"],
|
|
245
|
+
[9, "#el-late"],
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
it.each(PLAYHEADS)("at t=%s edits the tween under the playhead (%s)", async (time, expected) => {
|
|
249
|
+
document.body.innerHTML = "";
|
|
250
|
+
const el = document.createElement("div");
|
|
251
|
+
el.id = "el";
|
|
252
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
253
|
+
el.setAttribute("data-hf-studio-original-box-height", "408");
|
|
254
|
+
document.body.append(el);
|
|
255
|
+
usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
|
|
256
|
+
|
|
257
|
+
const animations = [scaleAt("#el-early", 0), scaleAt("#el-late", 4)];
|
|
258
|
+
const commitMutation = vi.fn();
|
|
259
|
+
await tryGsapResizeIntercept(
|
|
260
|
+
{ id: "el", selector: "#el", element: el } as DomEditSelection,
|
|
261
|
+
{ width: 326, height: 213 },
|
|
262
|
+
animations,
|
|
263
|
+
null,
|
|
264
|
+
commitMutation as never,
|
|
265
|
+
async () => animations,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const touched = new Set(
|
|
269
|
+
commitMutation.mock.calls
|
|
270
|
+
.map((call) => (call[1] as { animationId?: string }).animationId)
|
|
271
|
+
.filter((id): id is string => id != null),
|
|
272
|
+
);
|
|
273
|
+
expect([...touched]).toEqual([expected]);
|
|
274
|
+
});
|
|
@@ -313,8 +313,8 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
313
313
|
act(() => h.root.unmount());
|
|
314
314
|
});
|
|
315
315
|
|
|
316
|
-
it("does not apply the anchor twice when
|
|
317
|
-
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
316
|
+
it("does not apply the anchor twice when the resize already settled the drop point", async () => {
|
|
317
|
+
mocks.resize.mockResolvedValue({ status: "persisted", ownsDragOffset: true });
|
|
318
318
|
const scale = { propertyGroup: "scale" } as GsapAnimation;
|
|
319
319
|
const h = mountResizeHandler([scale]);
|
|
320
320
|
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
@@ -322,4 +322,21 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
322
322
|
expect(h.fallback).not.toHaveBeenCalled();
|
|
323
323
|
act(() => h.root.unmount());
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The same element, and the resize says it did NOT settle the drop point.
|
|
328
|
+
*
|
|
329
|
+
* This is the shape that broke: an element whose scale is an instant hold has
|
|
330
|
+
* a scale-group tween and still commits width/height. Reading the tweens said
|
|
331
|
+
* "scale route, it settles its own position", so the offset was withheld,
|
|
332
|
+
* nobody wrote it, and the element snapped back on every drag.
|
|
333
|
+
*/
|
|
334
|
+
it("applies the anchor when the resize leaves the drop point to the caller", async () => {
|
|
335
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
336
|
+
const scale = { propertyGroup: "scale" } as GsapAnimation;
|
|
337
|
+
const h = mountResizeHandler([scale]);
|
|
338
|
+
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
339
|
+
expect(mocks.drag).toHaveBeenCalledTimes(1);
|
|
340
|
+
act(() => h.root.unmount());
|
|
341
|
+
});
|
|
325
342
|
});
|
|
@@ -258,13 +258,21 @@ export function useGsapAwareEditing({
|
|
|
258
258
|
makeFetchFallback(selection),
|
|
259
259
|
);
|
|
260
260
|
assertGsapEditPersisted(outcome);
|
|
261
|
+
// What the resize actually did, not what its animations suggest
|
|
262
|
+
// it would do. An element whose scale is an instant hold has a
|
|
263
|
+
// scale-group tween and still commits width/height, so guessing
|
|
264
|
+
// from the tweens withheld an offset nobody had written and the
|
|
265
|
+
// element snapped back to its authored position on every drag.
|
|
266
|
+
const ownsDragOffset =
|
|
267
|
+
outcome.status === "persisted" && outcome.ownsDragOffset === true;
|
|
261
268
|
logResize("intercept-handled", {
|
|
262
269
|
scaleRoute,
|
|
263
|
-
|
|
270
|
+
ownsDragOffset,
|
|
271
|
+
willForwardOffset: !!(offset && !ownsDragOffset),
|
|
264
272
|
});
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
if (offset && !
|
|
273
|
+
// A resize that moved the element itself has already written
|
|
274
|
+
// where it landed. Everything else leaves the anchor to the drag.
|
|
275
|
+
if (offset && !ownsDragOffset) {
|
|
268
276
|
const dragOutcome = await tryGsapDragIntercept(
|
|
269
277
|
selection,
|
|
270
278
|
offset,
|
|
@@ -275,7 +283,7 @@ export function useGsapAwareEditing({
|
|
|
275
283
|
);
|
|
276
284
|
assertGsapEditPersisted(dragOutcome);
|
|
277
285
|
}
|
|
278
|
-
logResizeSettle(selection.element,
|
|
286
|
+
logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
|
|
279
287
|
return;
|
|
280
288
|
} catch (error) {
|
|
281
289
|
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
|
|
@@ -4,6 +4,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
4
4
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
5
5
|
import {
|
|
6
6
|
clearKeyframeCacheForElement,
|
|
7
|
+
elementCacheKeys,
|
|
7
8
|
pruneKeyframeCacheToFiles,
|
|
8
9
|
publishKeyframeCache,
|
|
9
10
|
writeGsapAnimationsForElement,
|
|
@@ -302,8 +303,9 @@ export function useGsapAnimationsForElement(
|
|
|
302
303
|
// scan already cached. Only clear when no source cached this element —
|
|
303
304
|
// otherwise selecting it would wipe its diamonds.
|
|
304
305
|
const { keyframeCache } = usePlayerStore.getState();
|
|
305
|
-
const hasCached =
|
|
306
|
-
keyframeCache.has(
|
|
306
|
+
const hasCached = elementCacheKeys(sourceFile, elementId).some((key) =>
|
|
307
|
+
keyframeCache.has(key),
|
|
308
|
+
);
|
|
307
309
|
if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
|
|
308
310
|
return;
|
|
309
311
|
}
|
|
@@ -314,14 +316,16 @@ export function useGsapAnimationsForElement(
|
|
|
314
316
|
...(ease ? { ease } : {}),
|
|
315
317
|
...(easeEach ? { easeEach } : {}),
|
|
316
318
|
};
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
319
|
+
// elementCacheKeys owns the key-variant list every writer sets (prefixed,
|
|
320
|
+
// index.html fallback, bare id). Building it by hand here is what let this
|
|
321
|
+
// site drift: it omitted the fallback key, and it wrote the bare id without
|
|
322
|
+
// the string coercion that keeps prune from throwing on a non-string. All
|
|
323
|
+
// keys land in one publish: a reader that woke between two separate writes
|
|
324
|
+
// saw the prefixed key updated and the bare one still stale.
|
|
322
325
|
publishKeyframeCache((draft) => {
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
|
327
|
+
draft.keyframeCache.set(key, merged);
|
|
328
|
+
}
|
|
325
329
|
});
|
|
326
330
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
327
331
|
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
|
@@ -426,13 +430,8 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
426
430
|
// in between re-rendered against a cache only partly filled in.
|
|
427
431
|
publishKeyframeCache((draft) => {
|
|
428
432
|
for (const [id, data] of scanned) {
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
const alreadyCached =
|
|
432
|
-
draft.keyframeCache.has(cacheKey) ||
|
|
433
|
-
draft.keyframeCache.has(fallbackKey) ||
|
|
434
|
-
draft.keyframeCache.has(id);
|
|
435
|
-
if (alreadyCached) continue;
|
|
433
|
+
const keys = elementCacheKeys(sf, id);
|
|
434
|
+
if (keys.some((key) => draft.keyframeCache.has(key))) continue;
|
|
436
435
|
// Skip position-only set tweens from runtime too, same filter as AST path
|
|
437
436
|
const isPosOnly =
|
|
438
437
|
data.keyframes.length === 1 &&
|
|
@@ -445,9 +444,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
445
444
|
keyframes: data.keyframes,
|
|
446
445
|
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
447
446
|
};
|
|
448
|
-
draft.keyframeCache.set(
|
|
449
|
-
if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
|
|
450
|
-
draft.keyframeCache.set(id, entry);
|
|
447
|
+
for (const key of keys) draft.keyframeCache.set(key, entry);
|
|
451
448
|
}
|
|
452
449
|
});
|
|
453
450
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
|
@@ -60,6 +60,11 @@ function renderPanelLayout() {
|
|
|
60
60
|
return renderPanelLayoutWith(usePanelLayout);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function resizeWindowTo(width: number) {
|
|
64
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
|
65
|
+
window.dispatchEvent(new Event("resize"));
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
describe("usePanelLayout — right inspector panes", () => {
|
|
64
69
|
it("opens Design with the intended viewport-scaled panel widths", () => {
|
|
65
70
|
const harness = renderPanelLayout();
|
|
@@ -160,6 +165,118 @@ describe("usePanelLayout — right inspector panes", () => {
|
|
|
160
165
|
harness.unmount();
|
|
161
166
|
});
|
|
162
167
|
|
|
168
|
+
it("caps a panel relative to the window instead of at a flat 600px", () => {
|
|
169
|
+
resizeWindowTo(700);
|
|
170
|
+
const harness = renderPanelLayout();
|
|
171
|
+
// The old flat cap let the inspector claim 600 of a 700px window.
|
|
172
|
+
expect(harness.getState().rightWidth).toBeLessThanOrEqual(280);
|
|
173
|
+
harness.unmount();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rails both panels once the window cannot fit them", () => {
|
|
177
|
+
resizeWindowTo(560);
|
|
178
|
+
const harness = renderPanelLayout();
|
|
179
|
+
expect(harness.getState()).toMatchObject({
|
|
180
|
+
effectiveLeftCollapsed: true,
|
|
181
|
+
effectiveRightCollapsed: true,
|
|
182
|
+
leftCollapsed: false,
|
|
183
|
+
rightCollapsed: false,
|
|
184
|
+
});
|
|
185
|
+
harness.unmount();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("auto-collapse never writes the user's persisted or URL-synced intent", () => {
|
|
189
|
+
const harness = renderPanelLayout();
|
|
190
|
+
act(() => resizeWindowTo(560));
|
|
191
|
+
|
|
192
|
+
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
|
193
|
+
// localStorage carries leftCollapsed; the shareable URL carries rightCollapsed.
|
|
194
|
+
// A ten-second window drag must rewrite neither.
|
|
195
|
+
expect(readStudioUiPreferences().leftCollapsed).toBeUndefined();
|
|
196
|
+
expect(harness.getState().leftCollapsed).toBe(false);
|
|
197
|
+
expect(harness.getState().rightCollapsed).toBe(false);
|
|
198
|
+
harness.unmount();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns the user's own width when the window grows back", () => {
|
|
202
|
+
const harness = renderPanelLayout();
|
|
203
|
+
const wide = harness.getState().leftWidth;
|
|
204
|
+
|
|
205
|
+
act(() => resizeWindowTo(560));
|
|
206
|
+
expect(harness.getState().leftWidth).toBeLessThan(wide);
|
|
207
|
+
|
|
208
|
+
act(() => resizeWindowTo(1496));
|
|
209
|
+
expect(harness.getState().leftWidth).toBe(wide);
|
|
210
|
+
harness.unmount();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps an explicitly collapsed sidebar collapsed after a narrow trip", () => {
|
|
214
|
+
const harness = renderPanelLayout();
|
|
215
|
+
act(() => harness.getState().toggleLeftSidebar());
|
|
216
|
+
expect(readStudioUiPreferences().leftCollapsed).toBe(true);
|
|
217
|
+
|
|
218
|
+
act(() => resizeWindowTo(560));
|
|
219
|
+
act(() => resizeWindowTo(1496));
|
|
220
|
+
|
|
221
|
+
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
|
222
|
+
harness.unmount();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("lets the user reopen a panel the window auto-collapsed", () => {
|
|
226
|
+
const harness = renderPanelLayout();
|
|
227
|
+
act(() => resizeWindowTo(560));
|
|
228
|
+
expect(harness.getState().effectiveRightCollapsed).toBe(true);
|
|
229
|
+
|
|
230
|
+
// Without this the header Inspector button would be dead below 700px.
|
|
231
|
+
act(() => harness.getState().setRightCollapsed(false));
|
|
232
|
+
expect(harness.getState().effectiveRightCollapsed).toBe(false);
|
|
233
|
+
harness.unmount();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("opens the sidebar when the rail's own button is clicked", () => {
|
|
237
|
+
const harness = renderPanelLayout();
|
|
238
|
+
act(() => resizeWindowTo(560));
|
|
239
|
+
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
|
240
|
+
|
|
241
|
+
// Regression: the toggle used to flip stored INTENT, which was already
|
|
242
|
+
// false here, so the click persisted leftCollapsed=true and the rail stayed
|
|
243
|
+
// railed — a dead button that silently saved a collapse nobody asked for.
|
|
244
|
+
act(() => harness.getState().toggleLeftSidebar());
|
|
245
|
+
|
|
246
|
+
expect(harness.getState().effectiveLeftCollapsed).toBe(false);
|
|
247
|
+
expect(harness.getState().leftCollapsed).toBe(false);
|
|
248
|
+
expect(readStudioUiPreferences().leftCollapsed).toBe(false);
|
|
249
|
+
// And it gets a real width: rendering an expanded sidebar at the 42px rail
|
|
250
|
+
// width would squash its own content. Only a real-UI click caught this.
|
|
251
|
+
expect(harness.getState().leftWidth).toBeGreaterThanOrEqual(200);
|
|
252
|
+
harness.unmount();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("closes the sidebar again on the next click", () => {
|
|
256
|
+
const harness = renderPanelLayout();
|
|
257
|
+
act(() => resizeWindowTo(560));
|
|
258
|
+
act(() => harness.getState().toggleLeftSidebar());
|
|
259
|
+
act(() => harness.getState().toggleLeftSidebar());
|
|
260
|
+
|
|
261
|
+
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
|
262
|
+
expect(readStudioUiPreferences().leftCollapsed).toBe(true);
|
|
263
|
+
harness.unmount();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("forgets that reopen once the window is wide again", () => {
|
|
267
|
+
const harness = renderPanelLayout();
|
|
268
|
+
act(() => resizeWindowTo(560));
|
|
269
|
+
act(() => harness.getState().setRightCollapsed(false));
|
|
270
|
+
expect(harness.getState().effectiveRightCollapsed).toBe(false);
|
|
271
|
+
|
|
272
|
+
// Widening past the threshold clears the override, so a later narrow trip
|
|
273
|
+
// rails again rather than staying open forever off one old click.
|
|
274
|
+
act(() => resizeWindowTo(1496));
|
|
275
|
+
act(() => resizeWindowTo(560));
|
|
276
|
+
expect(harness.getState().effectiveRightCollapsed).toBe(true);
|
|
277
|
+
harness.unmount();
|
|
278
|
+
});
|
|
279
|
+
|
|
163
280
|
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
|
|
164
281
|
vi.resetModules();
|
|
165
282
|
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|