@hyperframes/studio 0.7.78 → 0.7.80
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-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.css +0 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { parseGsapScript } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
+
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
|
|
7
|
+
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
|
|
8
|
+
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
9
|
+
import { promoteSetToKeyframes } from "./useEnableKeyframes";
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A selection built the way production builds it (getDomLayerPatchTarget), so
|
|
17
|
+
* the class-only case under test is the real one: buildStableSelector hands back
|
|
18
|
+
* a BARE class for an element with no id / hf-id / composition id.
|
|
19
|
+
*/
|
|
20
|
+
function selectionFor(el: HTMLElement): DomEditSelection {
|
|
21
|
+
const selector = buildStableSelector(el);
|
|
22
|
+
return {
|
|
23
|
+
element: el,
|
|
24
|
+
id: el.id || undefined,
|
|
25
|
+
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
26
|
+
selector,
|
|
27
|
+
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
|
|
28
|
+
sourceFile: "index.html",
|
|
29
|
+
dataAttributes: { start: "0", duration: "2" },
|
|
30
|
+
} as unknown as DomEditSelection;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Five class-only siblings — the shape that made one add collapse the timeline. */
|
|
34
|
+
function mountGroupSiblings(): HTMLElement[] {
|
|
35
|
+
document.body.innerHTML = `
|
|
36
|
+
<div id="scene" class="clip" data-start="0" data-duration="2">
|
|
37
|
+
<div class="group"></div>
|
|
38
|
+
<div class="group"></div>
|
|
39
|
+
<div class="group"></div>
|
|
40
|
+
<div class="group"></div>
|
|
41
|
+
<div class="group"></div>
|
|
42
|
+
</div>
|
|
43
|
+
`;
|
|
44
|
+
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BASE_SCRIPT = `
|
|
48
|
+
const tl = gsap.timeline({ paused: true });
|
|
49
|
+
tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
|
|
50
|
+
`.trim();
|
|
51
|
+
|
|
52
|
+
const KEYFRAMES = [
|
|
53
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
54
|
+
{ percentage: 100, properties: { x: 40 } },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Author the selector into a real script and read the tween's targets back. */
|
|
58
|
+
function roundTripTargets(selector: string): { script: string; targets: string[] } {
|
|
59
|
+
const { script } = addAnimationWithKeyframesToScript(BASE_SCRIPT, selector, 0, 1, KEYFRAMES);
|
|
60
|
+
const added = parseGsapScript(script).animations.find((a) => a.targetSelector === selector);
|
|
61
|
+
if (!added) throw new Error(`written selector did not re-parse: ${selector}`);
|
|
62
|
+
return { script, targets: resolveSelectorElementIds(added.targetSelector, document) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("writeTargetSelector", () => {
|
|
66
|
+
it("addresses exactly one element when the only identity is a shared class", () => {
|
|
67
|
+
const groups = mountGroupSiblings();
|
|
68
|
+
const selection = selectionFor(groups[2]);
|
|
69
|
+
|
|
70
|
+
// Precondition: this is the defect's input — a bare class hitting all five.
|
|
71
|
+
expect(selection.selector).toBe(".group");
|
|
72
|
+
expect(document.querySelectorAll(".group")).toHaveLength(5);
|
|
73
|
+
|
|
74
|
+
const written = writeTargetSelector(selection);
|
|
75
|
+
|
|
76
|
+
expect(written).toBeTruthy();
|
|
77
|
+
expect(document.querySelectorAll(written!)).toHaveLength(1);
|
|
78
|
+
expect(document.querySelector(written!)).toBe(groups[2]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps a unique #id target", () => {
|
|
82
|
+
document.body.innerHTML = `<div id="box" class="card"></div>`;
|
|
83
|
+
const el = document.querySelector<HTMLElement>("#box")!;
|
|
84
|
+
|
|
85
|
+
expect(writeTargetSelector(selectionFor(el))).toBe("#box");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("prefers data-hf-id over a generated structural selector", () => {
|
|
89
|
+
document.body.innerHTML = `
|
|
90
|
+
<div id="scene"><div class="group"></div><div class="group" data-hf-id="hf-42"></div></div>
|
|
91
|
+
`;
|
|
92
|
+
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
93
|
+
|
|
94
|
+
const written = writeTargetSelector(selectionFor(el));
|
|
95
|
+
|
|
96
|
+
expect(written).toBe('[data-hf-id="hf-42"]');
|
|
97
|
+
expect(document.querySelector(written!)).toBe(el);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps an already-unique class selector as authored", () => {
|
|
101
|
+
document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
|
|
102
|
+
const el = document.querySelector<HTMLElement>(".header")!;
|
|
103
|
+
|
|
104
|
+
expect(writeTargetSelector(selectionFor(el))).toBe(".header");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
|
|
108
|
+
// Mixed identity: the walk must step past the id-less <section>, stop at the
|
|
109
|
+
// data-hf-id row, and NOT keep climbing to #outer.
|
|
110
|
+
document.body.innerHTML = `
|
|
111
|
+
<div id="outer">
|
|
112
|
+
<header></header>
|
|
113
|
+
<section>
|
|
114
|
+
<div data-hf-id="mid">
|
|
115
|
+
<div class="cell"></div>
|
|
116
|
+
<div class="cell"></div>
|
|
117
|
+
<div class="cell"></div>
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
</div>
|
|
121
|
+
`;
|
|
122
|
+
const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
|
|
123
|
+
|
|
124
|
+
const written = writeTargetSelector(selectionFor(el));
|
|
125
|
+
|
|
126
|
+
expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
|
|
127
|
+
expect(document.querySelectorAll(written!)).toHaveLength(1);
|
|
128
|
+
expect(document.querySelector(written!)).toBe(el);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("falls through to the structural walk when the selection's selector is not valid CSS", () => {
|
|
132
|
+
document.body.innerHTML = `<div id="scene"><div></div><div></div></div>`;
|
|
133
|
+
const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
|
|
134
|
+
// querySelectorAll throws on this, so the "does it match exactly one?" rung
|
|
135
|
+
// must read as a miss rather than crashing the add.
|
|
136
|
+
const selection = { ...selectionFor(el), selector: "div[unclosed" } as DomEditSelection;
|
|
137
|
+
|
|
138
|
+
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns null when a live DOM is present and no rung addresses one element", () => {
|
|
142
|
+
document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
|
|
143
|
+
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
144
|
+
const selection = selectionFor(el);
|
|
145
|
+
expect(selection.selector).toBe(".group");
|
|
146
|
+
// Detached between selecting and committing: ownerDocument still resolves,
|
|
147
|
+
// but there is no parent to count a :nth-child position in. Returning
|
|
148
|
+
// ".group" here would re-author the very selector this function replaces.
|
|
149
|
+
el.remove();
|
|
150
|
+
|
|
151
|
+
expect(writeTargetSelector(selection)).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("still returns the selection's own selector when there is no DOM to check against", () => {
|
|
155
|
+
const selection = {
|
|
156
|
+
selector: ".group",
|
|
157
|
+
sourceFile: "index.html",
|
|
158
|
+
} as unknown as DomEditSelection;
|
|
159
|
+
|
|
160
|
+
expect(writeTargetSelector(selection)).toBe(".group");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("writeTargetSelector — write/read round trip", () => {
|
|
165
|
+
it("re-parses and attributes the new tween to the one element it targeted", () => {
|
|
166
|
+
const groups = mountGroupSiblings();
|
|
167
|
+
// Ids let resolveSelectorElementIds name the attributed elements; the
|
|
168
|
+
// SELECTION still has none, so the write path still faces the bare class.
|
|
169
|
+
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
|
|
170
|
+
const selection = { ...selectionFor(groups[2]), id: undefined } as DomEditSelection;
|
|
171
|
+
|
|
172
|
+
const written = writeTargetSelector(selection);
|
|
173
|
+
const { targets } = roundTripTargets(written!);
|
|
174
|
+
|
|
175
|
+
expect(targets).toEqual(["group-2"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not widen the add to the element's four class siblings", () => {
|
|
179
|
+
const groups = mountGroupSiblings();
|
|
180
|
+
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
|
|
181
|
+
const selection = { ...selectionFor(groups[0]), id: undefined } as DomEditSelection;
|
|
182
|
+
|
|
183
|
+
// The old bare-class write attributed the one add to every sibling — the
|
|
184
|
+
// attribution blow-up behind "one add collapsed the timeline to a single row".
|
|
185
|
+
expect(roundTripTargets(".group").targets).toHaveLength(5);
|
|
186
|
+
|
|
187
|
+
expect(roundTripTargets(writeTargetSelector(selection)!).targets).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("existingTweenTargetSelector", () => {
|
|
192
|
+
it("keeps the narrowed target when a replace re-authors the tween", async () => {
|
|
193
|
+
const groups = mountGroupSiblings();
|
|
194
|
+
const selection = selectionFor(groups[2]);
|
|
195
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
196
|
+
// A tween a previous add already narrowed to one sibling. Re-deriving the
|
|
197
|
+
// target from the selection would widen it back to all five.
|
|
198
|
+
const setAnim = {
|
|
199
|
+
id: "a1",
|
|
200
|
+
targetSelector: "#scene > div:nth-child(3)",
|
|
201
|
+
method: "set",
|
|
202
|
+
position: 0,
|
|
203
|
+
properties: { x: 10 },
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
await promoteSetToKeyframes({ commitMutation } as never, selection, setAnim as never, 0, null);
|
|
207
|
+
|
|
208
|
+
const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
|
|
209
|
+
expect(mutation.targetSelector).toBe("#scene > div:nth-child(3)");
|
|
210
|
+
expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("falls back to the selection when the tween's own target did not resolve", async () => {
|
|
214
|
+
const groups = mountGroupSiblings();
|
|
215
|
+
const selection = selectionFor(groups[2]);
|
|
216
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
217
|
+
const setAnim = {
|
|
218
|
+
id: "a1",
|
|
219
|
+
targetSelector: "targets[i]",
|
|
220
|
+
hasUnresolvedSelector: true,
|
|
221
|
+
method: "set",
|
|
222
|
+
position: 0,
|
|
223
|
+
properties: { x: 10 },
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await promoteSetToKeyframes({ commitMutation } as never, selection, setAnim as never, 0, null);
|
|
227
|
+
|
|
228
|
+
const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
|
|
229
|
+
expect(mutation.targetSelector).toBe(".group");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("commitKeyframeAtTimeImpl — new-tween target", () => {
|
|
234
|
+
it("authors a one-element selector when no tween exists at the playhead", async () => {
|
|
235
|
+
const groups = mountGroupSiblings();
|
|
236
|
+
const selection = selectionFor(groups[3]);
|
|
237
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
238
|
+
|
|
239
|
+
await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
|
|
240
|
+
|
|
241
|
+
const mutation = commitMutation.mock.calls[0]?.[1] as { targetSelector: string };
|
|
242
|
+
expect(mutation.targetSelector).not.toBe(".group");
|
|
243
|
+
expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
|
|
244
|
+
expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("tweenTargetsElement", () => {
|
|
249
|
+
it("matches a tween narrowed to this element by a selector the selection does not use", () => {
|
|
250
|
+
const groups = mountGroupSiblings();
|
|
251
|
+
groups[2]!.id = "narrowed";
|
|
252
|
+
|
|
253
|
+
// The write half authored "#narrowed"; the read half still has ".group".
|
|
254
|
+
expect(tweenTargetsElement("#narrowed", ".group", groups[2])).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not hand an individual element the group tween it merely inherits", () => {
|
|
258
|
+
const groups = mountGroupSiblings();
|
|
259
|
+
|
|
260
|
+
// Selecting one sibling by its own address must not let an edit mutate the
|
|
261
|
+
// ".group" tween: that write moves all five, not the one being nudged.
|
|
262
|
+
expect(tweenTargetsElement(".group", "#scene > div:nth-child(3)", groups[2])).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("still edits a group tween when the group itself is the selection", () => {
|
|
266
|
+
const groups = mountGroupSiblings();
|
|
267
|
+
|
|
268
|
+
expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("does not match a target that is not a selector matches() understands", () => {
|
|
272
|
+
const groups = mountGroupSiblings();
|
|
273
|
+
|
|
274
|
+
expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("commitKeyframeAtTimeImpl — no one-element target", () => {
|
|
279
|
+
it("drops the keyframe rather than authoring the bare class", async () => {
|
|
280
|
+
const groups = mountGroupSiblings();
|
|
281
|
+
const selection = selectionFor(groups[3]!);
|
|
282
|
+
groups[3]!.remove();
|
|
283
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
284
|
+
|
|
285
|
+
await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
|
|
286
|
+
|
|
287
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
describe("deduplicateKeyframes
|
|
58
|
-
it("
|
|
57
|
+
describe("deduplicateKeyframes colliding animation targets", () => {
|
|
58
|
+
it("records each animation's tween percentage in first-seen order", () => {
|
|
59
59
|
const merged = deduplicateKeyframes([
|
|
60
|
-
{
|
|
61
|
-
|
|
60
|
+
{
|
|
61
|
+
percentage: 45,
|
|
62
|
+
tweenPercentage: 20,
|
|
63
|
+
properties: { x: 10 },
|
|
64
|
+
ease: "power2.in",
|
|
65
|
+
animationId: "#a-position",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
percentage: 45,
|
|
69
|
+
tweenPercentage: 80,
|
|
70
|
+
properties: { opacity: 1 },
|
|
71
|
+
ease: "power2.out",
|
|
72
|
+
animationId: "#a-visual",
|
|
73
|
+
},
|
|
62
74
|
]);
|
|
63
75
|
const kf = merged.find((k) => k.percentage === 45);
|
|
64
|
-
expect(kf?.
|
|
76
|
+
expect(kf?.collidingAnimationTargets).toEqual([
|
|
77
|
+
{ animationId: "#a-position", tweenPercentage: 20 },
|
|
78
|
+
{ animationId: "#a-visual", tweenPercentage: 80 },
|
|
79
|
+
]);
|
|
65
80
|
});
|
|
66
81
|
|
|
67
|
-
it("
|
|
68
|
-
// The button can still only target one arbitrary animation, and each may
|
|
69
|
-
// inherit a different easeEach/animation ease that raw comparison misses.
|
|
82
|
+
it("deduplicates three colliding animations while preserving first-seen order", () => {
|
|
70
83
|
const merged = deduplicateKeyframes([
|
|
71
|
-
{
|
|
72
|
-
|
|
84
|
+
{
|
|
85
|
+
percentage: 45,
|
|
86
|
+
tweenPercentage: 20,
|
|
87
|
+
properties: { x: 10 },
|
|
88
|
+
ease: "power2.in",
|
|
89
|
+
animationId: "#a-position",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
percentage: 45,
|
|
93
|
+
tweenPercentage: 80,
|
|
94
|
+
properties: { opacity: 1 },
|
|
95
|
+
ease: "power2.in",
|
|
96
|
+
animationId: "#a-visual",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
percentage: 45,
|
|
100
|
+
tweenPercentage: 40,
|
|
101
|
+
properties: { y: 20 },
|
|
102
|
+
ease: "power2.out",
|
|
103
|
+
animationId: "#a-position",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
percentage: 45,
|
|
107
|
+
tweenPercentage: 60,
|
|
108
|
+
properties: { scale: 2 },
|
|
109
|
+
ease: "power2.in",
|
|
110
|
+
animationId: "#a-scale",
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toEqual([
|
|
114
|
+
{ animationId: "#a-position", tweenPercentage: 20 },
|
|
115
|
+
{ animationId: "#a-visual", tweenPercentage: 80 },
|
|
116
|
+
{ animationId: "#a-scale", tweenPercentage: 60 },
|
|
73
117
|
]);
|
|
74
|
-
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
|
|
75
118
|
});
|
|
76
119
|
|
|
77
|
-
it("
|
|
120
|
+
it("leaves the collision set undefined within a single animation", () => {
|
|
78
121
|
const merged = deduplicateKeyframes([
|
|
79
|
-
{
|
|
80
|
-
|
|
122
|
+
{
|
|
123
|
+
percentage: 45,
|
|
124
|
+
tweenPercentage: 20,
|
|
125
|
+
properties: { x: 10 },
|
|
126
|
+
ease: "power2.in",
|
|
127
|
+
animationId: "#a-position",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
percentage: 45,
|
|
131
|
+
tweenPercentage: 80,
|
|
132
|
+
properties: { y: 20 },
|
|
133
|
+
ease: "power2.out",
|
|
134
|
+
animationId: "#a-position",
|
|
135
|
+
},
|
|
81
136
|
]);
|
|
82
|
-
expect(merged.find((k) => k.percentage === 45)?.
|
|
137
|
+
expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toBeUndefined();
|
|
83
138
|
});
|
|
84
139
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
GsapAnimation,
|
|
3
3
|
GsapKeyframesData,
|
|
4
|
-
|
|
4
|
+
SourcedGsapPercentageKeyframe,
|
|
5
5
|
} from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
7
7
|
|
|
@@ -22,33 +22,60 @@ export function isStaticPositionHold(anim: GsapAnimation): boolean {
|
|
|
22
22
|
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
export interface AnimationKeyframeTarget {
|
|
26
|
+
animationId: string;
|
|
27
|
+
tweenPercentage: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function accumulateCollidingAnimationTargets(
|
|
31
|
+
keyframe: AnimationKeyframeTarget & {
|
|
32
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
33
|
+
},
|
|
34
|
+
incoming: AnimationKeyframeTarget,
|
|
35
|
+
): void {
|
|
36
|
+
const primaryId = keyframe.animationId;
|
|
37
|
+
// One tween meeting itself is not a collision. Both identity fields are
|
|
38
|
+
// required by the parameter types rather than guarded at runtime: a keyframe
|
|
39
|
+
// that arrives without them cannot be attributed to a tween at all, and an
|
|
40
|
+
// early return here would silently record no collision and let the inline
|
|
41
|
+
// ease button edit an arbitrary one of the tweens that met at this
|
|
42
|
+
// percentage. The compiler now refuses the incomplete keyframe instead.
|
|
43
|
+
if (primaryId === incoming.animationId) return;
|
|
44
|
+
const collisionTargets = keyframe.collidingAnimationTargets;
|
|
45
|
+
if (collisionTargets?.some((target) => target.animationId === incoming.animationId)) return;
|
|
46
|
+
keyframe.collidingAnimationTargets = [
|
|
47
|
+
...(collisionTargets === undefined || collisionTargets.length === 0
|
|
48
|
+
? [{ animationId: primaryId, tweenPercentage: keyframe.tweenPercentage }]
|
|
49
|
+
: collisionTargets),
|
|
50
|
+
{ animationId: incoming.animationId, tweenPercentage: incoming.tweenPercentage },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a keyframe looks like once it has been attributed to its source tween
|
|
56
|
+
* and is ready to be merged with the other tweens landing on the same row. The
|
|
57
|
+
* runtime scan produces unattributed keyframes and they never reach a merge, so
|
|
58
|
+
* they are deliberately not this type.
|
|
59
|
+
*/
|
|
60
|
+
export type MergeableKeyframe = SourcedGsapPercentageKeyframe & {
|
|
61
|
+
propertyGroup?: string;
|
|
62
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function deduplicateKeyframes<T extends MergeableKeyframe>(keyframes: T[]): T[] {
|
|
28
66
|
const byPct = new Map<number, T>();
|
|
29
67
|
for (const kf of keyframes) {
|
|
30
68
|
const existing = byPct.get(kf.percentage);
|
|
31
69
|
if (existing) {
|
|
32
70
|
existing.properties = { ...existing.properties, ...kf.properties };
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
// arbitrary
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
existing.animationId !== kf.animationId
|
|
42
|
-
) {
|
|
43
|
-
existing.easeAmbiguous = true;
|
|
44
|
-
}
|
|
45
|
-
// Whichever tween iterated last used to win `ease`, so the merged
|
|
46
|
-
// keyframe carried an arbitrary one of the colliding curves. Readers that
|
|
47
|
-
// do not check easeAmbiguous (drag readouts, lane hints) then showed a
|
|
48
|
-
// curve belonging to a different animation than the one an edit targets.
|
|
49
|
-
// Drop it instead: ambiguous means "no single ease", and the flag is the
|
|
50
|
-
// only honest answer.
|
|
51
|
-
if (existing.easeAmbiguous) delete existing.ease;
|
|
71
|
+
accumulateCollidingAnimationTargets(existing, kf);
|
|
72
|
+
// Whichever tween iterated last used to win `ease`, so the merged keyframe
|
|
73
|
+
// carried an arbitrary one of the colliding curves. Readers that show a
|
|
74
|
+
// single curve (drag readouts, lane hints, the inline ease button) then
|
|
75
|
+
// displayed one belonging to a different animation than the one an edit
|
|
76
|
+
// targets. A collision means "no single ease", and dropping it is the only
|
|
77
|
+
// honest answer; collidingAnimationTargets still names every tween there.
|
|
78
|
+
if ((existing.collidingAnimationTargets?.length ?? 0) > 1) delete existing.ease;
|
|
52
79
|
else if (kf.ease) existing.ease = kf.ease;
|
|
53
80
|
} else {
|
|
54
81
|
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
|
@@ -1,60 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reading a composition file's GSAP tweens into the keyframe cache: fetch
|
|
3
|
-
* selector -> element id resolution
|
|
2
|
+
* Reading a composition file's GSAP tweens into the keyframe cache: fetch and
|
|
3
|
+
* selector -> element id resolution.
|
|
4
4
|
* Split from useGsapTweenCache to keep that file under the 600-line limit.
|
|
5
5
|
*/
|
|
6
6
|
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
7
7
|
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
|
8
8
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
elementCacheKeys,
|
|
12
|
-
writeGsapAnimationsForElement,
|
|
13
|
-
} from "./gsapKeyframeCacheHelpers";
|
|
14
|
-
import { idFromSelector, toClipKeyframes } from "./gsapShared";
|
|
9
|
+
import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
|
|
10
|
+
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
15
11
|
import {
|
|
16
12
|
deduplicateKeyframes,
|
|
17
13
|
isStaticPositionHold,
|
|
18
14
|
synthesizeFlatTweenKeyframes,
|
|
15
|
+
type MergeableKeyframe,
|
|
19
16
|
} from "./gsapTweenSynth";
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
|
|
24
|
-
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
|
|
25
|
-
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
|
|
26
|
-
* element they animate — not just one parsed from the string. Falls back to a
|
|
27
|
-
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
|
|
28
|
-
*/
|
|
29
|
-
// fallow-ignore-next-line complexity
|
|
30
|
-
export function resolveSelectorElementIds(
|
|
31
|
-
selector: string,
|
|
32
|
-
doc: Document | null | undefined,
|
|
33
|
-
): string[] {
|
|
34
|
-
// A whole-selector id match (either shape) addresses exactly one element.
|
|
35
|
-
const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector)
|
|
36
|
-
? idFromSelector(selector)
|
|
37
|
-
: null;
|
|
38
|
-
if (bareId) return [bareId];
|
|
39
|
-
if (!doc) {
|
|
40
|
-
const lead = idFromSelector(selector);
|
|
41
|
-
return lead ? [lead] : [];
|
|
42
|
-
}
|
|
43
|
-
const ids = new Set<string>();
|
|
44
|
-
for (const part of selector.split(",")) {
|
|
45
|
-
const sel = part.trim();
|
|
46
|
-
if (!sel) continue;
|
|
47
|
-
try {
|
|
48
|
-
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
|
49
|
-
if (el.id) ids.add(el.id);
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
const lead = idFromSelector(sel);
|
|
53
|
-
if (lead) ids.add(lead);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return Array.from(ids);
|
|
57
|
-
}
|
|
18
|
+
export { resolveSelectorElementIds };
|
|
19
|
+
|
|
58
20
|
/**
|
|
59
21
|
* The slice of the parse response callers actually read. The endpoint returns
|
|
60
22
|
* the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
|
|
@@ -103,37 +65,6 @@ export async function fetchParsedAnimations(
|
|
|
103
65
|
}
|
|
104
66
|
}
|
|
105
67
|
|
|
106
|
-
/**
|
|
107
|
-
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
|
108
|
-
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
|
109
|
-
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
|
110
|
-
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
|
111
|
-
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
|
112
|
-
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
|
113
|
-
*/
|
|
114
|
-
export function resolveClipTimingBasis(
|
|
115
|
-
elementId: string,
|
|
116
|
-
sourceFile: string,
|
|
117
|
-
elements: ReadonlyArray<{
|
|
118
|
-
domId?: string;
|
|
119
|
-
key?: string;
|
|
120
|
-
id: string;
|
|
121
|
-
start: number;
|
|
122
|
-
duration: number;
|
|
123
|
-
}>,
|
|
124
|
-
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
|
125
|
-
): { elStart: number; elDuration: number } {
|
|
126
|
-
const direct = elements.find(
|
|
127
|
-
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
128
|
-
);
|
|
129
|
-
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
|
130
|
-
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
|
131
|
-
const host = hostId
|
|
132
|
-
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
|
133
|
-
: undefined;
|
|
134
|
-
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
68
|
/**
|
|
138
69
|
* Read one composition file's tweens into the keyframe cache. Split out of the
|
|
139
70
|
* hook so the effect can run it per file without re-nesting the whole body.
|
|
@@ -146,10 +77,8 @@ export async function populateKeyframeCacheFromAst(
|
|
|
146
77
|
): Promise<void> {
|
|
147
78
|
const parsed = await fetchParsedAnimations(projectId, sf);
|
|
148
79
|
if (!parsed) return;
|
|
149
|
-
const { setKeyframeCache } = usePlayerStore.getState();
|
|
150
|
-
clearKeyframeCacheForFile(sf);
|
|
151
80
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
152
|
-
const mergedByElement = new Map<string, GsapKeyframesData
|
|
81
|
+
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
|
|
153
82
|
const sourceByElement = new Map<string, GsapAnimation[]>();
|
|
154
83
|
for (const anim of parsed.animations) {
|
|
155
84
|
if (anim.hasUnresolvedKeyframes) continue;
|
|
@@ -174,8 +103,5 @@ export async function populateKeyframeCacheFromAst(
|
|
|
174
103
|
}
|
|
175
104
|
}
|
|
176
105
|
}
|
|
177
|
-
|
|
178
|
-
for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
|
|
179
|
-
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
|
|
180
|
-
}
|
|
106
|
+
replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
|
|
181
107
|
}
|