@hyperframes/studio 0.6.121 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-20PAVS4k.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parsePercentageKeyframes } from "./gsapShared";
3
+
4
+ describe("parsePercentageKeyframes", () => {
5
+ it("parses the object/percentage form", () => {
6
+ const out = parsePercentageKeyframes({ "0%": { x: 0, y: 0 }, "100%": { x: 9, y: 4 } });
7
+ expect(out?.keyframes).toEqual([
8
+ { percentage: 0, properties: { x: 0, y: 0 } },
9
+ { percentage: 100, properties: { x: 9, y: 4 } },
10
+ ]);
11
+ });
12
+
13
+ it("parses GSAP array-form keyframes as evenly-distributed steps", () => {
14
+ // Regression: a multi-point shuttle path authored as `keyframes: [...]` used to
15
+ // read as null (no `N%` keys) → no motion path. Steps map to i/(n-1)*100%.
16
+ const out = parsePercentageKeyframes([
17
+ { x: 0, y: 0 },
18
+ { x: 520, y: 120 },
19
+ { x: 1040, y: 0 },
20
+ { x: 1480, y: 160 },
21
+ ] as unknown as Record<string, unknown>);
22
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]);
23
+ expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
24
+ });
25
+
26
+ it("strips a per-entry ease without shifting the even index-spacing of the others", () => {
27
+ // GSAP positions array keyframes by array index, so a `{ ease }` carried on an
28
+ // entry is a segment ease (skipped as a property) — it must not change where
29
+ // the surrounding keyframes land. 3 entries → 0 / 50 / 100, even though the
30
+ // middle entry also carries an ease.
31
+ const out = parsePercentageKeyframes([
32
+ { x: 0 },
33
+ { x: 100, ease: "power2.in" },
34
+ { x: 200 },
35
+ ] as unknown as Record<string, unknown>);
36
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]);
37
+ expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]);
38
+ });
39
+
40
+ it("keeps even spacing when an interior array slot has no animatable prop", () => {
41
+ // A degenerate `{ ease }`-only slot contributes no output keyframe, but it is
42
+ // still an array slot GSAP allocates a position to — so the remaining entries
43
+ // keep their original i/(n-1) percentages (0 and 100 for a 3-slot array), not
44
+ // 0/100 collapsed onto a 2-entry spacing.
45
+ const out = parsePercentageKeyframes([
46
+ { x: 0 },
47
+ { ease: "power2.in" },
48
+ { x: 200 },
49
+ ] as unknown as Record<string, unknown>);
50
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
51
+ expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]);
52
+ });
53
+
54
+ it("returns null for keyframes with no positional/animatable props", () => {
55
+ expect(parsePercentageKeyframes([] as unknown as Record<string, unknown>)).toBeNull();
56
+ expect(parsePercentageKeyframes({})).toBeNull();
57
+ });
58
+ });
@@ -97,16 +97,6 @@ export function queryIframeElement(
97
97
  }
98
98
  }
99
99
 
100
- /** Safely access an iframe's contentDocument, returning null on cross-origin errors. */
101
- export function getIframeDocument(iframe: HTMLIFrameElement | null): Document | null {
102
- if (!iframe) return null;
103
- try {
104
- return iframe.contentDocument;
105
- } catch {
106
- return null;
107
- }
108
- }
109
-
110
100
  // ── Keyframe parsing ──────────────────────────────────────────────────────────
111
101
 
112
102
  export interface ParsedPercentageKeyframes {
@@ -125,6 +115,34 @@ export function parsePercentageKeyframes(
125
115
  const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
126
116
  let easeEach: string | undefined;
127
117
 
118
+ // GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are spread
119
+ // evenly across the tween by default: GSAP gives each entry an equal share of
120
+ // the duration unless an entry carries its own `duration`/`delay`, which the
121
+ // studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100).
122
+ // Index spacing counts EVERY array slot, including a degenerate entry that
123
+ // contributes no animatable prop (it's still a slot GSAP allocates a position
124
+ // to), so dropping such an entry from the output below must NOT shift the others.
125
+ // A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as
126
+ // a property; there is no array-form `easeEach` (that's an object-form sibling key).
127
+ // (The object form further down uses explicit "0%" keys instead.) Without this
128
+ // branch, array-keyframed tweens (e.g. a multi-point shuttle) read as null → no
129
+ // motion path.
130
+ if (Array.isArray(kfObj)) {
131
+ const steps = kfObj as unknown[];
132
+ steps.forEach((entry, i) => {
133
+ if (!entry || typeof entry !== "object") return;
134
+ const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
135
+ const properties: Record<string, number | string> = {};
136
+ for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
137
+ if (pk === "ease") continue;
138
+ if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
139
+ else if (typeof pv === "string") properties[pk] = pv;
140
+ }
141
+ if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
142
+ });
143
+ return keyframes.length > 0 ? { keyframes } : null;
144
+ }
145
+
128
146
  for (const [key, val] of Object.entries(kfObj)) {
129
147
  if (key === "easeEach") {
130
148
  if (typeof val === "string") easeEach = val;
@@ -1,5 +1,3 @@
1
- import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
2
-
3
1
  type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
4
2
 
5
3
  let _gsapCachedTimelines: Record<string, TimelineLike> | undefined;
@@ -50,7 +48,6 @@ export function isElementGsapTargeted(
50
48
  iframe: HTMLIFrameElement | null,
51
49
  element: HTMLElement,
52
50
  ): boolean {
53
- if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
54
51
  const timelines = readTimelines(iframe);
55
52
  if (!timelines) return false;
56
53
 
@@ -213,10 +213,8 @@ export function useDomEditSession({
213
213
  handleDomTextFieldStyleCommit,
214
214
  handleDomAddTextField,
215
215
  handleDomRemoveTextField,
216
- handleDomPathOffsetCommit,
217
216
  handleDomGroupPathOffsetCommit,
218
217
  handleDomBoxSizeCommit,
219
- handleDomRotationCommit,
220
218
  handleDomManualEditsReset,
221
219
  handleDomEditElementDelete,
222
220
  handleDomZIndexReorderCommit,
@@ -384,9 +382,7 @@ export function useDomEditSession({
384
382
  bumpGsapCache,
385
383
  makeFetchFallback,
386
384
  trackGsapInteractionFailure,
387
- handleDomPathOffsetCommit,
388
385
  handleDomBoxSizeCommit,
389
- handleDomRotationCommit,
390
386
  addGsapAnimation,
391
387
  convertToKeyframes,
392
388
  setArcPath,
@@ -4,7 +4,11 @@ import {
4
4
  getAllPreviewTargetsFromPointer,
5
5
  getPreviewTargetFromPointer,
6
6
  } from "../utils/studioPreviewHelpers";
7
- import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
7
+ import {
8
+ findMatchingTimelineElementId,
9
+ findTimelineIdByAncestor,
10
+ type RightPanelTab,
11
+ } from "../utils/studioHelpers";
8
12
  import {
9
13
  domEditSelectionsTargetSame,
10
14
  domEditSelectionInGroup,
@@ -178,10 +182,13 @@ export function useDomSelection({
178
182
  setRightCollapsed(false);
179
183
  setRightPanelTab("design");
180
184
  }
181
- const nextSelectedTimelineId = findMatchingTimelineElementId(
182
- nextSelection,
183
- timelineElements,
184
- );
185
+ const nextSelectedTimelineId =
186
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
187
+ findTimelineIdByAncestor(
188
+ nextSelection.element,
189
+ timelineElements,
190
+ nextSelection.sourceFile || "index.html",
191
+ );
185
192
  setSelectedTimelineElementId(nextSelectedTimelineId);
186
193
  return;
187
194
  }
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import {
4
+ animatedProps,
5
+ buildExtendedKeyframes,
6
+ isPlayheadWithinTween,
7
+ resolveNewTweenRange,
8
+ } from "./useEnableKeyframes";
9
+
10
+ function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
11
+ return {
12
+ id: "#el-to-0-position",
13
+ targetSelector: "#el",
14
+ method: "to",
15
+ position: 0,
16
+ properties: {},
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("resolveNewTweenRange", () => {
22
+ // Regression: "add a keyframe" must land at the PLAYHEAD. The runtime auto-stamps
23
+ // data-start="0" + data-duration=<rootDuration> on every GSAP element, so honoring
24
+ // data-start as authored timing put the keyframe at 0. Clamping the playhead into
25
+ // the element's range fixes it (auto-stamp's full range passes the playhead through).
26
+ it("anchors at the playhead through the auto-stamped full-composition range", () => {
27
+ // data-start="0", data-duration="14" (the auto-stamp), playhead 4.9 → 4.9
28
+ expect(resolveNewTweenRange("0", "14", 4.9)).toEqual({ start: 4.9, duration: 9.1 });
29
+ });
30
+
31
+ it("anchors at the playhead when the element has no authored range", () => {
32
+ expect(resolveNewTweenRange(undefined, undefined, 4)).toEqual({ start: 4, duration: 1 });
33
+ expect(resolveNewTweenRange(undefined, undefined, 6.123456).start).toBe(6.123);
34
+ });
35
+
36
+ it("never returns a negative start", () => {
37
+ expect(resolveNewTweenRange(undefined, undefined, -2).start).toBe(0);
38
+ });
39
+
40
+ it("clamps the playhead into a genuinely narrow authored clip", () => {
41
+ // clip [2.5, 8]: inside → playhead; before → start; after → end
42
+ expect(resolveNewTweenRange("2.5", "5.5", 4)).toEqual({ start: 4, duration: 4 });
43
+ expect(resolveNewTweenRange("2.5", "5.5", 1).start).toBe(2.5);
44
+ expect(resolveNewTweenRange("2.5", "5.5", 99).start).toBe(8);
45
+ });
46
+ });
47
+
48
+ describe("animatedProps", () => {
49
+ it("uses top-level properties when present (flat tween)", () => {
50
+ expect(animatedProps(anim({ properties: { x: -260 } }))).toEqual(["x"]);
51
+ });
52
+
53
+ it("derives props from keyframe stops when top-level properties is empty (array form)", () => {
54
+ // Regression: array-form `keyframes: [{x,y},…]` leaves `properties` empty, so
55
+ // add-keyframe read an empty prop list → empty position → silent no-op.
56
+ const a = anim({
57
+ properties: {},
58
+ keyframes: {
59
+ format: "object-array",
60
+ keyframes: [
61
+ { percentage: 0, properties: { x: 0, y: 0 } },
62
+ { percentage: 100, properties: { x: -460, y: -20 } },
63
+ ],
64
+ },
65
+ });
66
+ expect(animatedProps(a).sort()).toEqual(["x", "y"]);
67
+ });
68
+
69
+ it("falls back to x/y for a null anim or one with no resolvable props", () => {
70
+ expect(animatedProps(null)).toEqual(["x", "y"]);
71
+ expect(animatedProps(anim({ properties: {} }))).toEqual(["x", "y"]);
72
+ });
73
+ });
74
+
75
+ describe("isPlayheadWithinTween", () => {
76
+ const tween = anim({ position: 1.0, duration: 3.4 }); // range [1.0, 4.4]
77
+
78
+ it("is true inside the range (incl. boundaries)", () => {
79
+ expect(isPlayheadWithinTween(tween, 3.0)).toBe(true);
80
+ expect(isPlayheadWithinTween(tween, 1.0)).toBe(true);
81
+ expect(isPlayheadWithinTween(tween, 4.4)).toBe(true);
82
+ });
83
+
84
+ it("is false outside the tween range", () => {
85
+ expect(isPlayheadWithinTween(tween, 5.767)).toBe(false);
86
+ expect(isPlayheadWithinTween(tween, 0.5)).toBe(false);
87
+ });
88
+
89
+ it("does not block when the start can't be resolved", () => {
90
+ expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe("buildExtendedKeyframes", () => {
95
+ // puck-b: tween [1.0, 4.4], four evenly-distributed stops.
96
+ const kfAnim = anim({
97
+ position: 1.0,
98
+ duration: 3.4,
99
+ keyframes: {
100
+ format: "object-array",
101
+ keyframes: [
102
+ { percentage: 0, properties: { x: 0, y: 0 } },
103
+ { percentage: 33.3, properties: { x: -180, y: -60 } },
104
+ { percentage: 66.7, properties: { x: -320, y: 40 } },
105
+ { percentage: 100, properties: { x: -460, y: -20 } },
106
+ ],
107
+ },
108
+ });
109
+
110
+ it("extends the end and rescales existing stops to keep their absolute timing", () => {
111
+ const out = buildExtendedKeyframes(kfAnim, 5.767, { x: -460, y: -20 });
112
+ expect(out.position).toBe(1.0); // start unchanged
113
+ expect(out.duration).toBe(4.767); // grown to reach the playhead
114
+ // old end (abs 4.4) is no longer 100% — it slid back inside the longer range
115
+ const last = out.keyframes[out.keyframes.length - 1]!;
116
+ expect(last.percentage).toBe(100); // the new keyframe sits at the new end
117
+ expect(last.properties).toEqual({ x: -460, y: -20 });
118
+ expect(out.keyframes[0]!.percentage).toBe(0); // old start still anchors 0%
119
+ expect(out.keyframes.some((k) => k.percentage > 0 && k.percentage < 100)).toBe(true);
120
+ });
121
+
122
+ it("extends the start when the playhead precedes the tween", () => {
123
+ const out = buildExtendedKeyframes(kfAnim, 0, { x: 0, y: 0 });
124
+ expect(out.position).toBe(0); // start moved back to the playhead
125
+ expect(out.duration).toBe(4.4); // end (abs 4.4) unchanged
126
+ expect(out.keyframes[0]).toEqual({ percentage: 0, properties: { x: 0, y: 0 } });
127
+ // the old first stop (abs 1.0) is now partway in: 1.0 / 4.4 ≈ 22.7%
128
+ expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
129
+ });
130
+ });