@hyperframes/studio 0.6.86 → 0.6.88

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 (87) hide show
  1. package/dist/assets/index-B9_ctmee.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -126,45 +126,96 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
126
126
 
127
127
  for (const timeline of Object.values(timelines)) {
128
128
  if (!timeline?.getChildren) continue;
129
+ const tlDuration = typeof timeline.duration === "function" ? timeline.duration() : 0;
130
+
129
131
  for (const tween of timeline.getChildren(true)) {
130
132
  if (!tween.targets || !tween.vars) continue;
131
133
  const vars = tween.vars;
132
- if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
133
134
 
134
- const kfObj = vars.keyframes as Record<string, unknown>;
135
- const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
136
- [];
137
- let easeEach: string | undefined;
135
+ if (vars.keyframes && typeof vars.keyframes === "object") {
136
+ const kfObj = vars.keyframes as Record<string, unknown>;
137
+ const keyframes: Array<{
138
+ percentage: number;
139
+ properties: Record<string, number | string>;
140
+ }> = [];
141
+ let easeEach: string | undefined;
142
+
143
+ for (const [key, val] of Object.entries(kfObj)) {
144
+ if (key === "easeEach") {
145
+ if (typeof val === "string") easeEach = val;
146
+ continue;
147
+ }
148
+ const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
149
+ if (!pctMatch || !val || typeof val !== "object") continue;
150
+ const percentage = parseFloat(pctMatch[1]);
151
+ const properties: Record<string, number | string> = {};
152
+ for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
153
+ if (pk === "ease") continue;
154
+ if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
155
+ else if (typeof pv === "string") properties[pk] = pv;
156
+ }
157
+ if (Object.keys(properties).length > 0) {
158
+ keyframes.push({ percentage, properties });
159
+ }
160
+ }
138
161
 
139
- for (const [key, val] of Object.entries(kfObj)) {
140
- if (key === "easeEach") {
141
- if (typeof val === "string") easeEach = val;
162
+ if (keyframes.length > 0) {
163
+ keyframes.sort((a, b) => a.percentage - b.percentage);
164
+ for (const target of tween.targets()) {
165
+ const id = (target as HTMLElement).id;
166
+ if (id && !result.has(id)) {
167
+ result.set(id, { keyframes, easeEach });
168
+ }
169
+ }
142
170
  continue;
143
171
  }
144
- const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
145
- if (!pctMatch || !val || typeof val !== "object") continue;
146
- const percentage = parseFloat(pctMatch[1]);
147
- const properties: Record<string, number | string> = {};
148
- for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
149
- if (pk === "ease") continue;
150
- if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
151
- else if (typeof pv === "string") properties[pk] = pv;
152
- }
153
- if (Object.keys(properties).length > 0) {
154
- keyframes.push({ percentage, properties });
155
- }
156
172
  }
157
173
 
158
- if (keyframes.length === 0) continue;
159
- keyframes.sort((a, b) => a.percentage - b.percentage);
174
+ // Flat tweens: synthesize start + end keyframe entries
175
+ if (!tlDuration || tlDuration <= 0) continue;
176
+ const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
177
+ if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
178
+ const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
179
+
180
+ const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
181
+ const endPct =
182
+ tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
183
+ const properties: Record<string, number | string> = {};
184
+ const skip = new Set([
185
+ "ease",
186
+ "duration",
187
+ "delay",
188
+ "stagger",
189
+ "motionPath",
190
+ "overwrite",
191
+ "immediateRender",
192
+ "onComplete",
193
+ "onUpdate",
194
+ "onStart",
195
+ ]);
196
+ for (const [k, v] of Object.entries(vars)) {
197
+ if (skip.has(k)) continue;
198
+ if (typeof v === "number") properties[k] = Math.round(v * 1000) / 1000;
199
+ else if (typeof v === "string") properties[k] = v;
200
+ }
201
+ if (Object.keys(properties).length === 0) continue;
160
202
 
161
203
  for (const target of tween.targets()) {
162
204
  const id = (target as HTMLElement).id;
163
- if (id && !result.has(id)) {
164
- result.set(id, { keyframes, easeEach });
205
+ if (!id) continue;
206
+ const existing = result.get(id);
207
+ const entries = existing ?? { keyframes: [] };
208
+ entries.keyframes.push({ percentage: startPct, properties });
209
+ if (endPct !== startPct) {
210
+ entries.keyframes.push({ percentage: endPct, properties });
165
211
  }
212
+ if (!existing) result.set(id, entries);
166
213
  }
167
214
  }
168
215
  }
216
+
217
+ for (const entry of result.values()) {
218
+ entry.keyframes.sort((a, b) => a.percentage - b.percentage);
219
+ }
169
220
  return result;
170
221
  }
@@ -0,0 +1,19 @@
1
+ export function previewKeyframeChange(
2
+ iframe: HTMLIFrameElement | null,
3
+ selector: string,
4
+ properties: Record<string, number | string>,
5
+ ): boolean {
6
+ if (!iframe?.contentWindow) return false;
7
+ try {
8
+ const gsap = (
9
+ iframe.contentWindow as unknown as {
10
+ gsap?: { set: (target: string, vars: Record<string, number | string>) => void };
11
+ }
12
+ ).gsap;
13
+ if (!gsap?.set) return false;
14
+ gsap.set(selector, properties);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
@@ -81,6 +81,7 @@ interface UseAppHotkeysParams {
81
81
  onResetKeyframes: () => boolean;
82
82
  onDeleteSelectedKeyframes: () => void;
83
83
  onAfterUndoRedo?: () => void;
84
+ onToggleRecording?: () => void;
84
85
  }
85
86
 
86
87
  // ── Hook ──
@@ -106,6 +107,7 @@ export function useAppHotkeys({
106
107
  onResetKeyframes,
107
108
  onDeleteSelectedKeyframes,
108
109
  onAfterUndoRedo,
110
+ onToggleRecording,
109
111
  }: UseAppHotkeysParams) {
110
112
  const previewHotkeyWindowRef = useRef<Window | null>(null);
111
113
  const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
@@ -215,6 +217,8 @@ export function useAppHotkeys({
215
217
  onResetKeyframesRef.current = onResetKeyframes;
216
218
  const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
217
219
  onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
220
+ const onToggleRecordingRef = useRef(onToggleRecording);
221
+ onToggleRecordingRef.current = onToggleRecording;
218
222
 
219
223
  // ── Consolidated keydown handler ──
220
224
 
@@ -377,6 +381,20 @@ export function useAppHotkeys({
377
381
  void handleDomEditDeleteRef.current(domSelection);
378
382
  }
379
383
  }
384
+
385
+ // R — toggle gesture recording
386
+ if (
387
+ event.key === "r" &&
388
+ !event.metaKey &&
389
+ !event.ctrlKey &&
390
+ !event.altKey &&
391
+ !event.shiftKey &&
392
+ !isEditableTarget(event.target) &&
393
+ onToggleRecordingRef.current
394
+ ) {
395
+ event.preventDefault();
396
+ onToggleRecordingRef.current();
397
+ }
380
398
  };
381
399
 
382
400
  // ── Window keydown listener ──
@@ -3,6 +3,7 @@ import { copyTextToClipboard } from "../utils/clipboard";
3
3
  import { readTagSnippetByTarget } from "../utils/sourcePatcher";
4
4
  import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
5
5
  import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
6
+ import { usePlayerStore } from "../player";
6
7
 
7
8
  // ── Types ──
8
9
 
@@ -11,7 +12,6 @@ export interface UseAskAgentModalParams {
11
12
  activeCompPath: string | null;
12
13
  projectDir: string | null;
13
14
  projectIdRef: React.MutableRefObject<string | null>;
14
- currentTime: number;
15
15
  showToast: (message: string, tone?: "error" | "info") => void;
16
16
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
17
17
  domEditSelection: DomEditSelection | null;
@@ -23,7 +23,6 @@ export function useAskAgentModal({
23
23
  activeCompPath,
24
24
  projectDir,
25
25
  projectIdRef,
26
- currentTime,
27
26
  showToast,
28
27
  domEditSelectionRef,
29
28
  domEditSelection,
@@ -91,7 +90,7 @@ export function useAskAgentModal({
91
90
  const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
92
91
  const prompt = buildElementAgentPrompt({
93
92
  selection: domEditSelection,
94
- currentTime,
93
+ currentTime: usePlayerStore.getState().currentTime,
95
94
  tagSnippet,
96
95
  selectionContext: agentPromptSelectionContext,
97
96
  userInstruction,
@@ -115,7 +114,6 @@ export function useAskAgentModal({
115
114
  activeCompPath,
116
115
  agentPromptSelectionContext,
117
116
  agentPromptTagSnippet,
118
- currentTime,
119
117
  domEditSelection,
120
118
  projectDir,
121
119
  showToast,
@@ -5,7 +5,12 @@ import type { PatchOperation } from "../utils/sourcePatcher";
5
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
7
7
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
8
- import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
8
+ import {
9
+ buildDomEditPatchTarget,
10
+ getDomEditTargetKey,
11
+ readHfId,
12
+ type DomEditSelection,
13
+ } from "../components/editor/domEditing";
9
14
  import {
10
15
  applyStudioPathOffset,
11
16
  applyStudioBoxSize,
@@ -182,11 +187,7 @@ export function useDomEditCommits({
182
187
 
183
188
  if (options?.shouldSave && !options.shouldSave()) return;
184
189
 
185
- const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
186
- id: selection.id,
187
- selector: selection.selector,
188
- selectorIndex: selection.selectorIndex,
189
- };
190
+ const patchTarget = buildDomEditPatchTarget(selection);
190
191
 
191
192
  // Mark the save timestamp before the file write so the SSE file-change
192
193
  // handler suppresses the reload even if the event arrives before the
@@ -471,16 +472,8 @@ export function useDomEditCommits({
471
472
  if (typeof originalContent !== "string")
472
473
  throw new Error(`Missing file contents for ${targetPath}`);
473
474
 
474
- const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
475
- ? {
476
- id: selection.id,
477
- selector: selection.selector,
478
- selectorIndex: selection.selectorIndex,
479
- }
480
- : selection.selector
481
- ? { selector: selection.selector, selectorIndex: selection.selectorIndex }
482
- : ({} as never);
483
- if (!patchTarget.id && !patchTarget.selector) {
475
+ const patchTarget = buildDomEditPatchTarget(selection);
476
+ if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) {
484
477
  throw new Error("Selected element has no patchable target");
485
478
  }
486
479
 
@@ -541,7 +534,7 @@ export function useDomEditCommits({
541
534
  }>,
542
535
  ) => {
543
536
  if (entries.length === 0) return;
544
- const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? "el").join(":")}`;
537
+ const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
545
538
  for (let i = 0; i < entries.length; i++) {
546
539
  const entry = entries[i];
547
540
  entry.element.style.zIndex = String(entry.zIndex);
@@ -561,6 +554,7 @@ export function useDomEditCommits({
561
554
  {
562
555
  element: entry.element,
563
556
  id: entry.id ?? null,
557
+ hfId: readHfId(entry.element),
564
558
  selector: entry.selector,
565
559
  selectorIndex: entry.selectorIndex,
566
560
  sourceFile: entry.sourceFile,
@@ -50,7 +50,6 @@ export interface UseDomEditSessionParams {
50
50
  compositionLoading: boolean;
51
51
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
52
52
  timelineElements: TimelineElement[];
53
- currentTime: number;
54
53
  setSelectedTimelineElementId: (id: string | null) => void;
55
54
  setRightCollapsed: (collapsed: boolean) => void;
56
55
  setRightPanelTab: (tab: RightPanelTab) => void;
@@ -59,6 +58,7 @@ export interface UseDomEditSessionParams {
59
58
  queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
60
59
  readProjectFile: (path: string) => Promise<string>;
61
60
  writeProjectFile: (path: string, content: string) => Promise<void>;
61
+ updateEditingFileContent: (path: string, content: string) => void;
62
62
  domEditSaveTimestampRef: React.MutableRefObject<number>;
63
63
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
64
64
  fileTree: string[];
@@ -91,7 +91,6 @@ export function useDomEditSession({
91
91
  compositionLoading,
92
92
  previewIframeRef,
93
93
  timelineElements,
94
- currentTime,
95
94
  setSelectedTimelineElementId,
96
95
  setRightCollapsed,
97
96
  setRightPanelTab,
@@ -100,6 +99,7 @@ export function useDomEditSession({
100
99
  queueDomEditSave,
101
100
  readProjectFile: _readProjectFile,
102
101
  writeProjectFile,
102
+ updateEditingFileContent,
103
103
  domEditSaveTimestampRef,
104
104
  editHistory,
105
105
  fileTree,
@@ -182,7 +182,6 @@ export function useDomEditSession({
182
182
  activeCompPath,
183
183
  projectDir,
184
184
  projectIdRef,
185
- currentTime,
186
185
  showToast,
187
186
  domEditSelectionRef,
188
187
  domEditSelection,
@@ -224,12 +223,25 @@ export function useDomEditSession({
224
223
 
225
224
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
226
225
 
226
+ // Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe
227
+ // reload via refreshKey but don't go through commitMutation, so the cache
228
+ // would otherwise retain stale keyframe entries).
229
+ const prevRefreshKeyRef = useRef(refreshKey);
230
+ // eslint-disable-next-line no-restricted-syntax
231
+ useEffect(() => {
232
+ if (refreshKey !== prevRefreshKeyRef.current) {
233
+ prevRefreshKeyRef.current = refreshKey;
234
+ bumpGsapCache();
235
+ }
236
+ }, [refreshKey, bumpGsapCache]);
237
+
227
238
  const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
228
239
 
229
240
  usePopulateKeyframeCacheForFile(
230
241
  STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
231
242
  gsapSourceFile,
232
243
  gsapCacheVersion,
244
+ previewIframeRef,
233
245
  );
234
246
 
235
247
  const {
@@ -257,9 +269,12 @@ export function useDomEditSession({
257
269
  addGsapFromProperty,
258
270
  removeGsapFromProperty,
259
271
  addKeyframe,
272
+ addKeyframeBatch,
260
273
  removeKeyframe,
261
274
  convertToKeyframes,
262
275
  removeAllKeyframes,
276
+ setArcPath,
277
+ updateArcSegment,
263
278
  } = useGsapScriptCommits({
264
279
  projectIdRef,
265
280
  activeCompPath,
@@ -268,6 +283,7 @@ export function useDomEditSession({
268
283
  domEditSaveTimestampRef,
269
284
  reloadPreview,
270
285
  onCacheInvalidate: bumpGsapCache,
286
+ onFileContentChanged: updateEditingFileContent,
271
287
  });
272
288
 
273
289
  // ── Commit handlers (delegated to useDomEditCommits) ──
@@ -416,6 +432,7 @@ export function useDomEditSession({
416
432
  handleGsapAddFromProperty,
417
433
  handleGsapRemoveFromProperty,
418
434
  handleGsapAddKeyframe,
435
+ handleGsapAddKeyframeBatch,
419
436
  handleGsapRemoveKeyframe,
420
437
  handleGsapConvertToKeyframes,
421
438
  handleGsapRemoveAllKeyframes,
@@ -432,10 +449,10 @@ export function useDomEditSession({
432
449
  addGsapFromProperty,
433
450
  removeGsapFromProperty,
434
451
  addKeyframe,
452
+ addKeyframeBatch,
435
453
  removeKeyframe,
436
454
  convertToKeyframes,
437
455
  removeAllKeyframes,
438
- currentTime,
439
456
  handleDomManualEditsReset,
440
457
  selectedGsapAnimations,
441
458
  });
@@ -449,6 +466,22 @@ export function useDomEditSession({
449
466
  bumpGsapCache,
450
467
  });
451
468
 
469
+ const handleSetArcPath = useCallback(
470
+ (animId: string, config: Parameters<typeof setArcPath>[2]) => {
471
+ if (!domEditSelection) return;
472
+ setArcPath(domEditSelection, animId, config);
473
+ },
474
+ [domEditSelection, setArcPath],
475
+ );
476
+
477
+ const handleUpdateArcSegment = useCallback(
478
+ (animId: string, segmentIndex: number, update: Parameters<typeof updateArcSegment>[3]) => {
479
+ if (!domEditSelection) return;
480
+ updateArcSegment(domEditSelection, animId, segmentIndex, update);
481
+ },
482
+ [domEditSelection, updateArcSegment],
483
+ );
484
+
452
485
  // Sync selection from preview document on load / refresh
453
486
  // eslint-disable-next-line no-restricted-syntax
454
487
  useEffect(() => {
@@ -589,12 +622,22 @@ export function useDomEditSession({
589
622
  handleGsapAddFromProperty,
590
623
  handleGsapRemoveFromProperty,
591
624
  handleGsapAddKeyframe,
625
+ handleGsapAddKeyframeBatch,
592
626
  handleGsapRemoveKeyframe,
593
627
  handleGsapConvertToKeyframes,
594
628
  handleGsapRemoveAllKeyframes,
595
629
  handleResetSelectedElementKeyframes,
596
630
  commitAnimatedProperty,
631
+ handleSetArcPath,
632
+ handleUpdateArcSegment,
597
633
  invalidateGsapCache: bumpGsapCache,
598
634
  previewIframeRef,
635
+ commitMutation: async (
636
+ mutation: Record<string, unknown>,
637
+ options: { label: string; softReload?: boolean },
638
+ ) => {
639
+ if (!domEditSelection) return;
640
+ await gsapCommitMutation(domEditSelection, mutation, options);
641
+ },
599
642
  };
600
643
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Centralized "Enable keyframes" logic that handles ALL scenarios:
3
+ * - Element has explicit keyframes → add/remove at seeked time
4
+ * - Element has a flat tween → convert + add at seeked time + propagate to end
5
+ * - Element has no animation (deleted) → create new tween with correct position + keyframes
6
+ *
7
+ * Always fetches fresh animation data to avoid stale session state.
8
+ * Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
9
+ */
10
+ import { useCallback } from "react";
11
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
+ import { usePlayerStore } from "../player/store/playerStore";
14
+ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
15
+
16
+ export interface EnableKeyframesSession {
17
+ domEditSelection: DomEditSelection | null;
18
+ selectedGsapAnimations: GsapAnimation[];
19
+ previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
20
+ handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
21
+ handleGsapConvertToKeyframes: (
22
+ animId: string,
23
+ resolvedFromValues?: Record<string, number | string>,
24
+ ) => void | Promise<void>;
25
+ handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
26
+ handleGsapAddKeyframeBatch?: (
27
+ animId: string,
28
+ pct: number,
29
+ properties: Record<string, number | string>,
30
+ ) => Promise<void>;
31
+ commitMutation?: (
32
+ mutation: Record<string, unknown>,
33
+ options: { label: string; softReload?: boolean },
34
+ ) => Promise<void>;
35
+ }
36
+
37
+ function readElementPosition(
38
+ iframe: HTMLIFrameElement | null,
39
+ sel: DomEditSelection,
40
+ anim: GsapAnimation | null,
41
+ ): Record<string, number> {
42
+ const result: Record<string, number> = {};
43
+ if (!iframe?.contentWindow) return result;
44
+
45
+ let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
46
+ try {
47
+ gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
48
+ } catch {
49
+ return result;
50
+ }
51
+
52
+ const element = sel.element;
53
+ if (!element?.isConnected || !gsap?.getProperty) return result;
54
+
55
+ const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
56
+ for (const prop of props) {
57
+ const val = Number(gsap.getProperty(element, prop));
58
+ if (Number.isFinite(val)) result[prop] = Math.round(val);
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
65
+ const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
66
+ if (!projectId) return [];
67
+ const sourceFile = sel.sourceFile || "index.html";
68
+ const parsed = await fetchParsedAnimations(projectId, sourceFile);
69
+ if (!parsed) return [];
70
+ return getAnimationsForElement(parsed.animations, {
71
+ id: sel.id,
72
+ selector: sel.selector,
73
+ });
74
+ }
75
+
76
+ function computePercentage(t: number, sel: DomEditSelection): number {
77
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
78
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
79
+ if (elDuration <= 0) return 0;
80
+ return Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10));
81
+ }
82
+
83
+ // fallow-ignore-next-line complexity
84
+ export function useEnableKeyframes(
85
+ sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
86
+ ) {
87
+ return useCallback(async () => {
88
+ const session = sessionRef.current;
89
+ if (!session) return;
90
+ const sel = session.domEditSelection;
91
+ if (!sel) return;
92
+
93
+ const t = usePlayerStore.getState().currentTime;
94
+ const iframe = session.previewIframeRef?.current ?? null;
95
+
96
+ let anims = session.selectedGsapAnimations;
97
+ if (anims.length === 0) {
98
+ anims = await fetchAnimationsForElement(sel);
99
+ }
100
+
101
+ const kfAnim = anims.find((a) => a.keyframes);
102
+ const flatAnim = anims.find((a) => !a.keyframes);
103
+
104
+ if (kfAnim?.keyframes) {
105
+ const pct = computePercentage(t, sel);
106
+ const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
107
+ if (existing) {
108
+ session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
109
+ } else if (session.handleGsapAddKeyframeBatch) {
110
+ const position = readElementPosition(iframe, sel, kfAnim);
111
+ if (Object.keys(position).length > 0) {
112
+ await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
113
+ }
114
+ }
115
+ } else if (flatAnim) {
116
+ const position = readElementPosition(iframe, sel, flatAnim);
117
+ const hasPosition = Object.keys(position).length > 0;
118
+
119
+ await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined);
120
+
121
+ const pct = computePercentage(t, sel);
122
+ if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) {
123
+ await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position);
124
+ await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position);
125
+ }
126
+ } else {
127
+ const position = readElementPosition(iframe, sel, null);
128
+ const pct = computePercentage(t, sel);
129
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
130
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
131
+ const selector = sel.id ? `#${sel.id}` : sel.selector;
132
+
133
+ if (!selector) {
134
+ session.handleGsapAddAnimation("to");
135
+ return;
136
+ }
137
+
138
+ if (Object.keys(position).length === 0) {
139
+ position.x = 0;
140
+ position.y = 0;
141
+ position.opacity = 1;
142
+ }
143
+
144
+ const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
145
+ [{ percentage: 0, properties: { ...position } }];
146
+ if (pct > 1 && pct < 99) {
147
+ keyframes.push({ percentage: pct, properties: { ...position } });
148
+ }
149
+ keyframes.push({
150
+ percentage: 100,
151
+ properties: { ...position },
152
+ auto: true,
153
+ } as (typeof keyframes)[number]);
154
+
155
+ if (session.commitMutation) {
156
+ await session.commitMutation(
157
+ {
158
+ type: "add-with-keyframes",
159
+ targetSelector: selector,
160
+ position: Math.round(elStart * 1000) / 1000,
161
+ duration: Math.round(elDuration * 1000) / 1000,
162
+ keyframes,
163
+ },
164
+ { label: "Enable keyframes", softReload: true },
165
+ );
166
+ } else {
167
+ session.handleGsapAddAnimation("to");
168
+ }
169
+ }
170
+ }, [sessionRef]);
171
+ }
@@ -108,6 +108,12 @@ export function useFileManager({
108
108
  }
109
109
  }, []);
110
110
 
111
+ const updateEditingFileContent = useCallback((path: string, content: string) => {
112
+ if (editingPathRef.current === path) {
113
+ setEditingFile({ path, content });
114
+ }
115
+ }, []);
116
+
111
117
  const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
112
118
  const pid = projectIdRef.current;
113
119
  if (!pid) throw new Error("No active project");
@@ -460,6 +466,7 @@ export function useFileManager({
460
466
  readProjectFile,
461
467
  writeProjectFile,
462
468
  readOptionalProjectFile,
469
+ updateEditingFileContent,
463
470
 
464
471
  // Click-to-source
465
472
  revealSourceOffset,