@hyperframes/studio 0.6.52 → 0.6.54

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 (31) hide show
  1. package/dist/assets/index-CKJCBFsG.js +138 -0
  2. package/dist/assets/index-ZdgB8MFr.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/components/StudioFeedbackBar.tsx +208 -0
  6. package/src/components/StudioPreviewArea.tsx +97 -92
  7. package/src/components/StudioRightPanel.tsx +18 -0
  8. package/src/components/editor/AnimationCard.tsx +325 -0
  9. package/src/components/editor/EaseCurveSection.tsx +213 -0
  10. package/src/components/editor/GsapAnimationSection.tsx +112 -0
  11. package/src/components/editor/PropertyPanel.tsx +48 -18
  12. package/src/components/editor/domEditingTypes.ts +2 -0
  13. package/src/components/editor/gsapAnimationConstants.ts +130 -0
  14. package/src/components/editor/manualEditingAvailability.ts +6 -0
  15. package/src/components/editor/manualEdits.test.ts +101 -0
  16. package/src/components/editor/manualEdits.ts +22 -9
  17. package/src/components/editor/manualEditsDom.ts +22 -21
  18. package/src/components/editor/manualOffsetDrag.test.ts +35 -22
  19. package/src/components/editor/manualOffsetDrag.ts +1 -7
  20. package/src/components/editor/propertyPanelPrimitives.tsx +6 -1
  21. package/src/contexts/DomEditContext.tsx +27 -0
  22. package/src/hooks/useDomEditSession.ts +98 -2
  23. package/src/hooks/useDomSelection.ts +8 -0
  24. package/src/hooks/useGsapScriptCommits.ts +303 -0
  25. package/src/hooks/useGsapTweenCache.ts +80 -0
  26. package/src/hooks/usePreviewPersistence.ts +1 -0
  27. package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +2 -1
  29. package/src/telemetry/events.ts +32 -0
  30. package/dist/assets/index-Bvy50smZ.js +0 -138
  31. package/dist/assets/index-SKRp8mGz.css +0 -1
@@ -103,6 +103,8 @@ export function MetricField({
103
103
  disabled,
104
104
  liveCommit,
105
105
  scrub,
106
+ suffix,
107
+ tooltip,
106
108
  onCommit,
107
109
  }: {
108
110
  label: string;
@@ -110,6 +112,8 @@ export function MetricField({
110
112
  disabled?: boolean;
111
113
  liveCommit?: boolean;
112
114
  scrub?: boolean;
115
+ suffix?: string;
116
+ tooltip?: string;
113
117
  onCommit: (nextValue: string) => void;
114
118
  }) {
115
119
  const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
@@ -151,7 +155,7 @@ export function MetricField({
151
155
  : ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
152
156
 
153
157
  return (
154
- <div className={FIELD}>
158
+ <div className={FIELD} title={tooltip}>
155
159
  <div className="flex min-w-0 items-center gap-3">
156
160
  <span {...scrubProps}>{label}</span>
157
161
  <CommitField
@@ -160,6 +164,7 @@ export function MetricField({
160
164
  liveCommit={liveCommit}
161
165
  onCommit={onCommit}
162
166
  />
167
+ {suffix && <span className="flex-shrink-0 text-[10px] text-neutral-600">{suffix}</span>}
163
168
  </div>
164
169
  </div>
165
170
  );
@@ -53,6 +53,15 @@ export function DomEditProvider({
53
53
  setAgentModalOpen,
54
54
  setAgentPromptSelectionContext,
55
55
  setAgentModalAnchorPoint,
56
+ selectedGsapAnimations,
57
+ gsapMultipleTimelines,
58
+ gsapUnsupportedTimelinePattern,
59
+ handleGsapUpdateProperty,
60
+ handleGsapUpdateMeta,
61
+ handleGsapDeleteAnimation,
62
+ handleGsapAddAnimation,
63
+ handleGsapAddProperty,
64
+ handleGsapRemoveProperty,
56
65
  },
57
66
  children,
58
67
  }: {
@@ -101,6 +110,15 @@ export function DomEditProvider({
101
110
  setAgentModalOpen,
102
111
  setAgentPromptSelectionContext,
103
112
  setAgentModalAnchorPoint,
113
+ selectedGsapAnimations,
114
+ gsapMultipleTimelines,
115
+ gsapUnsupportedTimelinePattern,
116
+ handleGsapUpdateProperty,
117
+ handleGsapUpdateMeta,
118
+ handleGsapDeleteAnimation,
119
+ handleGsapAddAnimation,
120
+ handleGsapAddProperty,
121
+ handleGsapRemoveProperty,
104
122
  }),
105
123
  [
106
124
  domEditSelection,
@@ -143,6 +161,15 @@ export function DomEditProvider({
143
161
  setAgentModalOpen,
144
162
  setAgentPromptSelectionContext,
145
163
  setAgentModalAnchorPoint,
164
+ selectedGsapAnimations,
165
+ gsapMultipleTimelines,
166
+ gsapUnsupportedTimelinePattern,
167
+ handleGsapUpdateProperty,
168
+ handleGsapUpdateMeta,
169
+ handleGsapDeleteAnimation,
170
+ handleGsapAddAnimation,
171
+ handleGsapAddProperty,
172
+ handleGsapRemoveProperty,
146
173
  ],
147
174
  );
148
175
  return <DomEditContext value={stable}>{children}</DomEditContext>;
@@ -1,7 +1,11 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
- import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
3
+ import {
4
+ STUDIO_INSPECTOR_PANELS_ENABLED,
5
+ STUDIO_GSAP_PANEL_ENABLED,
6
+ } from "../components/editor/manualEditingAvailability";
4
7
  import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
8
+ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
5
9
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
6
10
  import type { EditHistoryKind } from "../utils/editHistory";
7
11
  import type { RightPanelTab } from "../utils/studioHelpers";
@@ -11,6 +15,8 @@ import { useAskAgentModal } from "./useAskAgentModal";
11
15
  import { useDomSelection } from "./useDomSelection";
12
16
  import { usePreviewInteraction } from "./usePreviewInteraction";
13
17
  import { useDomEditCommits } from "./useDomEditCommits";
18
+ import { useGsapScriptCommits } from "./useGsapScriptCommits";
19
+ import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache";
14
20
 
15
21
  // ── Types ──
16
22
 
@@ -185,6 +191,37 @@ export function useDomEditSession({
185
191
  onClickToSource,
186
192
  });
187
193
 
194
+ // ── GSAP script editing ──
195
+
196
+ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
197
+
198
+ const {
199
+ animations: selectedGsapAnimations,
200
+ multipleTimelines: gsapMultipleTimelines,
201
+ unsupportedTimelinePattern: gsapUnsupportedTimelinePattern,
202
+ } = useGsapAnimationsForElement(
203
+ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
204
+ domEditSelection?.sourceFile || activeCompPath || "index.html",
205
+ domEditSelection?.id ?? null,
206
+ gsapCacheVersion,
207
+ );
208
+
209
+ const {
210
+ updateGsapProperty,
211
+ updateGsapMeta,
212
+ deleteGsapAnimation,
213
+ addGsapAnimation,
214
+ addGsapProperty,
215
+ removeGsapProperty,
216
+ } = useGsapScriptCommits({
217
+ projectIdRef,
218
+ activeCompPath,
219
+ editHistory,
220
+ domEditSaveTimestampRef,
221
+ reloadPreview,
222
+ onCacheInvalidate: bumpGsapCache,
223
+ });
224
+
188
225
  // ── Commit handlers (delegated to useDomEditCommits) ──
189
226
 
190
227
  const {
@@ -224,7 +261,53 @@ export function useDomEditSession({
224
261
  buildDomSelectionFromTarget,
225
262
  });
226
263
 
227
- // ── Effects ──
264
+ const handleGsapUpdateProperty = useCallback(
265
+ (animId: string, prop: string, value: number | string) => {
266
+ if (!domEditSelection) return;
267
+ updateGsapProperty(domEditSelection, animId, prop, value);
268
+ },
269
+ [domEditSelection, updateGsapProperty],
270
+ );
271
+
272
+ const handleGsapUpdateMeta = useCallback(
273
+ (animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
274
+ if (!domEditSelection) return;
275
+ updateGsapMeta(domEditSelection, animId, updates);
276
+ },
277
+ [domEditSelection, updateGsapMeta],
278
+ );
279
+
280
+ const handleGsapDeleteAnimation = useCallback(
281
+ (animId: string) => {
282
+ if (!domEditSelection) return;
283
+ deleteGsapAnimation(domEditSelection, animId);
284
+ },
285
+ [domEditSelection, deleteGsapAnimation],
286
+ );
287
+
288
+ const handleGsapAddAnimation = useCallback(
289
+ (method: "to" | "from" | "set") => {
290
+ if (!domEditSelection) return;
291
+ addGsapAnimation(domEditSelection, method, currentTime);
292
+ },
293
+ [domEditSelection, addGsapAnimation, currentTime],
294
+ );
295
+
296
+ const handleGsapAddProperty = useCallback(
297
+ (animId: string, prop: string) => {
298
+ if (!domEditSelection) return;
299
+ addGsapProperty(domEditSelection, animId, prop);
300
+ },
301
+ [domEditSelection, addGsapProperty],
302
+ );
303
+
304
+ const handleGsapRemoveProperty = useCallback(
305
+ (animId: string, prop: string) => {
306
+ if (!domEditSelection) return;
307
+ removeGsapProperty(domEditSelection, animId, prop);
308
+ },
309
+ [domEditSelection, removeGsapProperty],
310
+ );
228
311
 
229
312
  // Sync selection from preview document on load / refresh
230
313
  // eslint-disable-next-line no-restricted-syntax
@@ -243,6 +326,8 @@ export function useDomEditSession({
243
326
  }
244
327
  if (!doc) return;
245
328
 
329
+ reapplyPositionEditsAfterSeek(doc);
330
+
246
331
  const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
247
332
  if (!nextElement) {
248
333
  applyDomSelection(null, { revealPanel: false });
@@ -345,5 +430,16 @@ export function useDomEditSession({
345
430
  setAgentModalOpen,
346
431
  setAgentPromptSelectionContext,
347
432
  setAgentModalAnchorPoint,
433
+
434
+ // GSAP script editing
435
+ selectedGsapAnimations,
436
+ gsapMultipleTimelines,
437
+ gsapUnsupportedTimelinePattern,
438
+ handleGsapUpdateProperty,
439
+ handleGsapUpdateMeta,
440
+ handleGsapDeleteAnimation,
441
+ handleGsapAddAnimation,
442
+ handleGsapAddProperty,
443
+ handleGsapRemoveProperty,
348
444
  };
349
445
  }
@@ -16,6 +16,7 @@ import {
16
16
  resolveDomEditSelection,
17
17
  type DomEditSelection,
18
18
  } from "../components/editor/domEditing";
19
+ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
19
20
 
20
21
  // ── Types ──
21
22
 
@@ -218,6 +219,11 @@ export function useDomSelection({
218
219
  ) => {
219
220
  const iframe = previewIframeRef.current;
220
221
  if (!iframe || captionEditMode) return null;
222
+ try {
223
+ if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument);
224
+ } catch {
225
+ /* cross-origin guard */
226
+ }
221
227
  const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
222
228
  if (!target) return null;
223
229
  return buildDomSelectionFromTarget(target, {
@@ -245,6 +251,8 @@ export function useDomSelection({
245
251
  }
246
252
  if (!doc) return null;
247
253
 
254
+ reapplyPositionEditsAfterSeek(doc);
255
+
248
256
  const targetElement = findElementForTimelineElement(doc, element, {
249
257
  activeCompositionPath: activeCompPath,
250
258
  compIdToSrc,
@@ -0,0 +1,303 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import type { EditHistoryKind } from "../utils/editHistory";
5
+
6
+ const PROPERTY_DEFAULTS: Record<string, number> = {
7
+ opacity: 1,
8
+ x: 0,
9
+ y: 0,
10
+ scale: 1,
11
+ scaleX: 1,
12
+ scaleY: 1,
13
+ rotation: 0,
14
+ width: 100,
15
+ height: 100,
16
+ };
17
+
18
+ /**
19
+ * Ensures the element has an id so it can be targeted by a GSAP selector.
20
+ * If the element already has an id or a CSS selector, returns those.
21
+ * Otherwise mints a unique id and sets it on the live element.
22
+ */
23
+ function ensureElementAddressable(selection: DomEditSelection): {
24
+ selector: string;
25
+ autoId?: string;
26
+ } {
27
+ if (selection.id) return { selector: `#${selection.id}` };
28
+ if (selection.selector) return { selector: selection.selector };
29
+
30
+ const el = selection.element;
31
+ const doc = el.ownerDocument;
32
+ const tag = el.tagName.toLowerCase();
33
+ let id = tag;
34
+ let n = 1;
35
+ while (doc.getElementById(id)) {
36
+ n += 1;
37
+ id = `${tag}-${n}`;
38
+ }
39
+ el.setAttribute("id", id);
40
+ return { selector: `#${id}`, autoId: id };
41
+ }
42
+
43
+ interface MutationResult {
44
+ ok: boolean;
45
+ parsed?: ParsedGsap;
46
+ before?: string;
47
+ after?: string;
48
+ }
49
+
50
+ async function mutateGsapScript(
51
+ projectId: string,
52
+ sourceFile: string,
53
+ mutation: Record<string, unknown>,
54
+ ): Promise<MutationResult | null> {
55
+ try {
56
+ const res = await fetch(
57
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
58
+ {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(mutation),
62
+ },
63
+ );
64
+ if (!res.ok) return null;
65
+ return (await res.json()) as MutationResult;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ interface GsapScriptCommitsParams {
72
+ projectIdRef: React.MutableRefObject<string | null>;
73
+ activeCompPath: string | null;
74
+ editHistory: {
75
+ recordEdit: (entry: {
76
+ label: string;
77
+ kind: EditHistoryKind;
78
+ coalesceKey?: string;
79
+ files: Record<string, { before: string; after: string }>;
80
+ }) => Promise<void>;
81
+ };
82
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
83
+ reloadPreview: () => void;
84
+ onCacheInvalidate: () => void;
85
+ }
86
+
87
+ const DEBOUNCE_MS = 150;
88
+
89
+ // fallow-ignore-next-line complexity unit-size
90
+ export function useGsapScriptCommits({
91
+ projectIdRef,
92
+ activeCompPath,
93
+ editHistory,
94
+ domEditSaveTimestampRef,
95
+ reloadPreview,
96
+ onCacheInvalidate,
97
+ }: GsapScriptCommitsParams) {
98
+ const pendingPropertyEditRef = useRef<{
99
+ selection: DomEditSelection;
100
+ animationId: string;
101
+ property: string;
102
+ value: number | string;
103
+ } | null>(null);
104
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
105
+
106
+ /** Send a mutation and record the edit in undo history. */
107
+ const commitMutation = useCallback(
108
+ async (
109
+ selection: DomEditSelection,
110
+ mutation: Record<string, unknown>,
111
+ options: { label: string; coalesceKey?: string; softReload?: boolean },
112
+ ) => {
113
+ const pid = projectIdRef.current;
114
+ if (!pid) return;
115
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
116
+
117
+ const result = await mutateGsapScript(pid, targetPath, mutation);
118
+ if (!result?.ok) return;
119
+
120
+ domEditSaveTimestampRef.current = Date.now();
121
+
122
+ if (result.before != null && result.after != null) {
123
+ await editHistory.recordEdit({
124
+ label: options.label,
125
+ kind: "manual",
126
+ coalesceKey: options.coalesceKey,
127
+ files: { [targetPath]: { before: result.before, after: result.after } },
128
+ });
129
+ }
130
+
131
+ onCacheInvalidate();
132
+
133
+ if (!options.softReload) {
134
+ reloadPreview();
135
+ }
136
+ },
137
+ [
138
+ projectIdRef,
139
+ activeCompPath,
140
+ editHistory,
141
+ domEditSaveTimestampRef,
142
+ reloadPreview,
143
+ onCacheInvalidate,
144
+ ],
145
+ );
146
+
147
+ const flushPendingPropertyEdit = useCallback(() => {
148
+ const pending = pendingPropertyEditRef.current;
149
+ if (!pending) return;
150
+ pendingPropertyEditRef.current = null;
151
+ const { selection, animationId, property, value } = pending;
152
+ void commitMutation(
153
+ selection,
154
+ { type: "update-property", animationId, property, value },
155
+ {
156
+ label: `Edit GSAP ${property}`,
157
+ coalesceKey: `gsap:${animationId}:${property}`,
158
+ },
159
+ );
160
+ }, [commitMutation]);
161
+
162
+ const updateGsapProperty = useCallback(
163
+ (
164
+ selection: DomEditSelection,
165
+ animationId: string,
166
+ property: string,
167
+ value: number | string,
168
+ ) => {
169
+ pendingPropertyEditRef.current = { selection, animationId, property, value };
170
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
171
+ debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS);
172
+ },
173
+ [flushPendingPropertyEdit],
174
+ );
175
+
176
+ useEffect(() => {
177
+ return () => {
178
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
179
+ flushPendingPropertyEdit();
180
+ };
181
+ }, [flushPendingPropertyEdit]);
182
+
183
+ const updateGsapMeta = useCallback(
184
+ (
185
+ selection: DomEditSelection,
186
+ animationId: string,
187
+ updates: { duration?: number; ease?: string; position?: number },
188
+ ) => {
189
+ void commitMutation(
190
+ selection,
191
+ { type: "update-meta", animationId, updates },
192
+ {
193
+ label: "Edit GSAP animation",
194
+ coalesceKey: `gsap:${animationId}:meta`,
195
+ },
196
+ );
197
+ },
198
+ [commitMutation],
199
+ );
200
+
201
+ const deleteGsapAnimation = useCallback(
202
+ (selection: DomEditSelection, animationId: string) => {
203
+ void commitMutation(
204
+ selection,
205
+ { type: "delete", animationId },
206
+ { label: "Delete GSAP animation" },
207
+ );
208
+ },
209
+ [commitMutation],
210
+ );
211
+
212
+ const addGsapAnimation = useCallback(
213
+ async (selection: DomEditSelection, method: "to" | "from" | "set", currentTime?: number) => {
214
+ const { selector, autoId } = ensureElementAddressable(selection);
215
+
216
+ if (autoId) {
217
+ const pid = projectIdRef.current;
218
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
219
+ if (!pid) return;
220
+ const res = await fetch(
221
+ `/api/projects/${encodeURIComponent(pid)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
222
+ {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify({
226
+ target: {
227
+ id: selection.id,
228
+ selector: selection.selector,
229
+ selectorIndex: selection.selectorIndex,
230
+ },
231
+ operations: [{ type: "html-attribute", property: "id", value: autoId }],
232
+ }),
233
+ },
234
+ );
235
+ if (!res.ok) return;
236
+ const data = (await res.json()) as { changed?: boolean };
237
+ if (!data.changed) return;
238
+ }
239
+
240
+ const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0);
241
+ const defaults: Record<string, Record<string, number>> = {
242
+ from: { opacity: 0 },
243
+ to: { opacity: 1 },
244
+ set: { opacity: 1 },
245
+ };
246
+
247
+ await commitMutation(
248
+ selection,
249
+ {
250
+ type: "add",
251
+ targetSelector: selector,
252
+ method,
253
+ position: start,
254
+ duration: method === "set" ? undefined : 0.5,
255
+ ease: method === "set" ? undefined : "power2.out",
256
+ properties: defaults[method] ?? { opacity: 1 },
257
+ },
258
+ { label: `Add GSAP ${method} animation` },
259
+ );
260
+ },
261
+ [commitMutation, projectIdRef, activeCompPath],
262
+ );
263
+
264
+ const addGsapProperty = useCallback(
265
+ (selection: DomEditSelection, animationId: string, property: string) => {
266
+ let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
267
+ const el = selection.element;
268
+ if (property === "width" || property === "height") {
269
+ const rect = el.getBoundingClientRect();
270
+ defaultValue = Math.round(property === "width" ? rect.width : rect.height);
271
+ } else if (property === "opacity" || property === "autoAlpha") {
272
+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
273
+ defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1;
274
+ }
275
+ void commitMutation(
276
+ selection,
277
+ { type: "add-property", animationId, property, defaultValue },
278
+ { label: `Add GSAP ${property}` },
279
+ );
280
+ },
281
+ [commitMutation],
282
+ );
283
+
284
+ const removeGsapProperty = useCallback(
285
+ (selection: DomEditSelection, animationId: string, property: string) => {
286
+ void commitMutation(
287
+ selection,
288
+ { type: "remove-property", animationId, property },
289
+ { label: `Remove GSAP ${property}` },
290
+ );
291
+ },
292
+ [commitMutation],
293
+ );
294
+
295
+ return {
296
+ updateGsapProperty,
297
+ updateGsapMeta,
298
+ deleteGsapAnimation,
299
+ addGsapAnimation,
300
+ addGsapProperty,
301
+ removeGsapProperty,
302
+ };
303
+ }
@@ -0,0 +1,80 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
+
4
+ function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
5
+ return animations.filter((a) => a.targetSelector === `#${elementId}`);
6
+ }
7
+
8
+ async function fetchParsedAnimations(
9
+ projectId: string,
10
+ sourceFile: string,
11
+ ): Promise<ParsedGsap | null> {
12
+ try {
13
+ const res = await fetch(
14
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
15
+ );
16
+ return res.ok ? ((await res.json()) as ParsedGsap) : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function useGsapAnimationsForElement(
23
+ projectId: string | null,
24
+ sourceFile: string,
25
+ elementId: string | null,
26
+ version: number,
27
+ ): {
28
+ animations: GsapAnimation[];
29
+ multipleTimelines: boolean;
30
+ unsupportedTimelinePattern: boolean;
31
+ } {
32
+ const [allAnimations, setAllAnimations] = useState<GsapAnimation[]>([]);
33
+ const [multipleTimelines, setMultipleTimelines] = useState(false);
34
+ const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
35
+ const lastFetchKeyRef = useRef("");
36
+
37
+ useEffect(() => {
38
+ const fetchKey = `${projectId}:${sourceFile}:${version}`;
39
+ if (fetchKey === lastFetchKeyRef.current) return;
40
+ lastFetchKeyRef.current = fetchKey;
41
+
42
+ if (!projectId) {
43
+ setAllAnimations([]);
44
+ setMultipleTimelines(false);
45
+ setUnsupportedTimelinePattern(false);
46
+ return;
47
+ }
48
+
49
+ let cancelled = false;
50
+ fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
51
+ if (cancelled) return;
52
+ if (!parsed) {
53
+ setAllAnimations([]);
54
+ setMultipleTimelines(false);
55
+ setUnsupportedTimelinePattern(false);
56
+ return;
57
+ }
58
+ setAllAnimations(parsed.animations);
59
+ setMultipleTimelines(parsed.multipleTimelines === true);
60
+ setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
61
+ });
62
+
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, [projectId, sourceFile, version]);
67
+
68
+ const animations = useMemo(
69
+ () => (elementId ? getAnimationsForElement(allAnimations, elementId) : []),
70
+ [allAnimations, elementId],
71
+ );
72
+
73
+ return { animations, multipleTimelines, unsupportedTimelinePattern };
74
+ }
75
+
76
+ export function useGsapCacheVersion() {
77
+ const [version, setVersion] = useState(0);
78
+ const bump = useCallback(() => setVersion((v) => v + 1), []);
79
+ return { version, bump };
80
+ }
@@ -102,6 +102,7 @@ export function usePreviewPersistence({
102
102
  }
103
103
  if (d) reapplyPositionEditsAfterSeek(d);
104
104
  };
105
+
105
106
  const install = () => {
106
107
  reapply();
107
108
  if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);