@hyperframes/studio 0.6.85 → 0.6.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DRpY3xHh.js → hyperframes-player-0esDKGRk.js} +1 -1
- package/dist/assets/index-BA19FAPN.js +143 -0
- package/dist/assets/index-CGlIm_-E.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +159 -6
- package/src/components/StudioHeader.tsx +20 -7
- package/src/components/StudioPreviewArea.tsx +6 -1
- package/src/components/StudioRightPanel.tsx +13 -0
- package/src/components/StudioToast.tsx +47 -7
- package/src/components/TimelineToolbar.tsx +12 -122
- package/src/components/editor/AnimationCard.tsx +64 -10
- package/src/components/editor/ArcPathControls.tsx +131 -0
- package/src/components/editor/BorderRadiusEditor.tsx +209 -0
- package/src/components/editor/DomEditOverlay.tsx +70 -11
- package/src/components/editor/DopesheetStrip.tsx +141 -0
- package/src/components/editor/EaseCurveSection.tsx +82 -7
- package/src/components/editor/GestureTrailOverlay.tsx +132 -0
- package/src/components/editor/GsapAnimationSection.tsx +14 -1
- package/src/components/editor/KeyframeDiamond.tsx +27 -12
- package/src/components/editor/LayersPanel.tsx +14 -12
- package/src/components/editor/MotionPathOverlay.tsx +146 -0
- package/src/components/editor/PropertyPanel.tsx +196 -66
- package/src/components/editor/SourceEditor.tsx +0 -1
- package/src/components/editor/StaggerControls.tsx +61 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +2 -1
- package/src/components/editor/domEditing.test.ts +43 -0
- package/src/components/editor/domEditing.ts +2 -0
- package/src/components/editor/domEditingElement.ts +25 -2
- package/src/components/editor/domEditingLayers.test.ts +78 -0
- package/src/components/editor/domEditingLayers.ts +33 -13
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +23 -5
- package/src/components/editor/manualOffsetDrag.ts +59 -0
- package/src/components/editor/panelTokens.ts +10 -0
- package/src/components/editor/propertyPanelColor.tsx +2 -2
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +18 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
- package/src/components/editor/propertyPanelSections.tsx +4 -6
- package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
- package/src/components/editor/useDomEditOverlayRects.ts +46 -2
- package/src/components/renders/RenderQueue.tsx +121 -100
- package/src/components/renders/RenderQueueItem.tsx +13 -13
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +0 -4
- package/src/hooks/gsapKeyframeCommit.ts +92 -0
- package/src/hooks/gsapRuntimeBridge.ts +147 -85
- package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
- package/src/hooks/gsapRuntimePreview.ts +19 -0
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useAskAgentModal.ts +2 -4
- package/src/hooks/useDomEditCommits.ts +11 -17
- package/src/hooks/useDomEditSession.ts +47 -4
- package/src/hooks/useEnableKeyframes.ts +171 -0
- package/src/hooks/useFileManager.ts +7 -0
- package/src/hooks/useGestureRecording.ts +340 -0
- package/src/hooks/useGsapScriptCommits.ts +171 -35
- package/src/hooks/useGsapSelectionHandlers.ts +27 -8
- package/src/hooks/useGsapTweenCache.ts +169 -11
- package/src/hooks/useKeyframeKeyboard.ts +103 -0
- package/src/hooks/useStudioContextValue.ts +5 -4
- package/src/hooks/useStudioUrlState.ts +1 -2
- package/src/hooks/useTimelineEditing.ts +50 -3
- package/src/hooks/useToast.ts +6 -1
- package/src/player/components/ShortcutsPanel.tsx +40 -0
- package/src/player/components/TimelineClipDiamonds.tsx +3 -3
- package/src/player/components/TimelinePropertyRows.tsx +120 -0
- package/src/player/lib/timelineDOM.test.ts +55 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +43 -0
- package/src/utils/audioBeatDetection.ts +58 -0
- package/src/utils/globalTimeCompiler.test.ts +169 -0
- package/src/utils/globalTimeCompiler.ts +77 -0
- package/src/utils/gsapSoftReload.ts +30 -10
- package/src/utils/keyframeSnapping.test.ts +74 -0
- package/src/utils/keyframeSnapping.ts +63 -0
- package/src/utils/rdpSimplify.ts +183 -0
- package/src/utils/sourcePatcher.ts +2 -0
- package/dist/assets/index-DHcptK1_.css +0 -1
- package/dist/assets/index-DtSCUvYQ.js +0 -140
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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 (
|
|
164
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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,
|