@hyperframes/studio 0.7.77 → 0.7.79

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.
Files changed (77) hide show
  1. package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1432 -1181
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +70 -33
  27. package/src/hooks/gsapDragCommit.ts +43 -10
  28. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  29. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  31. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  32. package/src/hooks/gsapResizeIntercept.ts +2 -2
  33. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  34. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  35. package/src/hooks/gsapShared.test.ts +144 -0
  36. package/src/hooks/gsapShared.ts +264 -0
  37. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  38. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  39. package/src/hooks/newTweenTarget.test.ts +364 -0
  40. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  41. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  42. package/src/hooks/timelineTrackVisibility.ts +11 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  44. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  45. package/src/hooks/useEnableKeyframes.ts +18 -5
  46. package/src/hooks/useGestureCommit.ts +21 -4
  47. package/src/hooks/useGsapScriptCommits.ts +3 -0
  48. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  49. package/src/hooks/useGsapTweenCache.ts +4 -11
  50. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  51. package/src/player/components/Player.test.ts +143 -12
  52. package/src/player/components/Player.tsx +84 -43
  53. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  54. package/src/player/components/ShortcutsPanel.tsx +11 -15
  55. package/src/player/components/Timeline.test.ts +3 -1
  56. package/src/player/components/TimelineCanvas.tsx +2 -1
  57. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  58. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  59. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  60. package/src/player/components/TimelineLanes.test.tsx +281 -0
  61. package/src/player/components/TimelineLanes.tsx +43 -97
  62. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  63. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  64. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  65. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  66. package/src/player/components/timelineLaneProps.ts +86 -0
  67. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  68. package/src/player/components/timelineTrackDisplay.ts +38 -0
  69. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  70. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  72. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  73. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  74. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  75. package/src/telemetry/client.test.ts +12 -3
  76. package/src/telemetry/client.ts +3 -2
  77. package/dist/assets/index-CBAfprvx.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
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Reading a composition file's GSAP tweens into the keyframe cache: fetch,
3
- * selector -> element id resolution, and the clip-relative timing basis.
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";
@@ -11,50 +11,15 @@ import {
11
11
  elementCacheKeys,
12
12
  writeGsapAnimationsForElement,
13
13
  } from "./gsapKeyframeCacheHelpers";
14
- import { idFromSelector, toClipKeyframes } from "./gsapShared";
14
+ import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
15
15
  import {
16
16
  deduplicateKeyframes,
17
17
  isStaticPositionHold,
18
18
  synthesizeFlatTweenKeyframes,
19
19
  } from "./gsapTweenSynth";
20
20
 
21
- /**
22
- * Resolve a tween's target selector to the ids of the element(s) it animates.
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
- }
21
+ export { resolveSelectorElementIds };
22
+
58
23
  /**
59
24
  * The slice of the parse response callers actually read. The endpoint returns
60
25
  * the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
@@ -103,37 +68,6 @@ export async function fetchParsedAnimations(
103
68
  }
104
69
  }
105
70
 
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
71
  /**
138
72
  * Read one composition file's tweens into the keyframe cache. Split out of the
139
73
  * hook so the effect can run it per file without re-nesting the whole body.