@hyperframes/studio 0.6.73 → 0.6.74
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/index-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- package/dist/assets/index-Dc2HfqON.js +0 -140
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thin useCallback wrappers that guard on `domEditSelection` before
|
|
6
|
+
* delegating to the underlying GSAP script-commit functions. Extracted
|
|
7
|
+
* from useDomEditSession to keep that file under the 600-line limit.
|
|
8
|
+
*/
|
|
9
|
+
// fallow-ignore-next-line complexity
|
|
10
|
+
export function useGsapSelectionHandlers({
|
|
11
|
+
domEditSelection,
|
|
12
|
+
updateGsapProperty,
|
|
13
|
+
updateGsapMeta,
|
|
14
|
+
deleteGsapAnimation,
|
|
15
|
+
addGsapAnimation,
|
|
16
|
+
addGsapProperty,
|
|
17
|
+
removeGsapProperty,
|
|
18
|
+
updateGsapFromProperty,
|
|
19
|
+
addGsapFromProperty,
|
|
20
|
+
removeGsapFromProperty,
|
|
21
|
+
addKeyframe,
|
|
22
|
+
removeKeyframe,
|
|
23
|
+
convertToKeyframes,
|
|
24
|
+
removeAllKeyframes,
|
|
25
|
+
currentTime,
|
|
26
|
+
handleDomManualEditsReset,
|
|
27
|
+
selectedGsapAnimations,
|
|
28
|
+
}: {
|
|
29
|
+
domEditSelection: DomEditSelection | null;
|
|
30
|
+
updateGsapProperty: (
|
|
31
|
+
sel: DomEditSelection,
|
|
32
|
+
animId: string,
|
|
33
|
+
prop: string,
|
|
34
|
+
value: number | string,
|
|
35
|
+
) => void;
|
|
36
|
+
updateGsapMeta: (
|
|
37
|
+
sel: DomEditSelection,
|
|
38
|
+
animId: string,
|
|
39
|
+
updates: { duration?: number; ease?: string; position?: number },
|
|
40
|
+
) => void;
|
|
41
|
+
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
|
|
42
|
+
addGsapAnimation: (
|
|
43
|
+
sel: DomEditSelection,
|
|
44
|
+
method: "to" | "from" | "set" | "fromTo",
|
|
45
|
+
time: number,
|
|
46
|
+
) => void;
|
|
47
|
+
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
|
48
|
+
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
|
49
|
+
updateGsapFromProperty: (
|
|
50
|
+
sel: DomEditSelection,
|
|
51
|
+
animId: string,
|
|
52
|
+
prop: string,
|
|
53
|
+
value: number | string,
|
|
54
|
+
) => void;
|
|
55
|
+
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
|
56
|
+
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
|
57
|
+
addKeyframe: (
|
|
58
|
+
sel: DomEditSelection,
|
|
59
|
+
animId: string,
|
|
60
|
+
percentage: number,
|
|
61
|
+
property: string,
|
|
62
|
+
value: number | string,
|
|
63
|
+
) => void;
|
|
64
|
+
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
|
65
|
+
convertToKeyframes: (sel: DomEditSelection, animId: string) => void;
|
|
66
|
+
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
|
67
|
+
currentTime: number;
|
|
68
|
+
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
|
69
|
+
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
|
|
70
|
+
}) {
|
|
71
|
+
const handleGsapUpdateProperty = useCallback(
|
|
72
|
+
(animId: string, prop: string, value: number | string) => {
|
|
73
|
+
if (!domEditSelection) return;
|
|
74
|
+
updateGsapProperty(domEditSelection, animId, prop, value);
|
|
75
|
+
},
|
|
76
|
+
[domEditSelection, updateGsapProperty],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const handleGsapUpdateMeta = useCallback(
|
|
80
|
+
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
|
|
81
|
+
if (!domEditSelection) return;
|
|
82
|
+
updateGsapMeta(domEditSelection, animId, updates);
|
|
83
|
+
},
|
|
84
|
+
[domEditSelection, updateGsapMeta],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const handleGsapDeleteAnimation = useCallback(
|
|
88
|
+
(animId: string) => {
|
|
89
|
+
if (!domEditSelection) return;
|
|
90
|
+
deleteGsapAnimation(domEditSelection, animId);
|
|
91
|
+
},
|
|
92
|
+
[domEditSelection, deleteGsapAnimation],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const handleGsapAddAnimation = useCallback(
|
|
96
|
+
(method: "to" | "from" | "set" | "fromTo") => {
|
|
97
|
+
if (!domEditSelection) return;
|
|
98
|
+
addGsapAnimation(domEditSelection, method, currentTime);
|
|
99
|
+
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
|
100
|
+
handleDomManualEditsReset(domEditSelection);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
[domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const handleGsapAddProperty = useCallback(
|
|
107
|
+
(animId: string, prop: string) => {
|
|
108
|
+
if (!domEditSelection) return;
|
|
109
|
+
addGsapProperty(domEditSelection, animId, prop);
|
|
110
|
+
},
|
|
111
|
+
[domEditSelection, addGsapProperty],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleGsapRemoveProperty = useCallback(
|
|
115
|
+
(animId: string, prop: string) => {
|
|
116
|
+
if (!domEditSelection) return;
|
|
117
|
+
removeGsapProperty(domEditSelection, animId, prop);
|
|
118
|
+
},
|
|
119
|
+
[domEditSelection, removeGsapProperty],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const handleGsapUpdateFromProperty = useCallback(
|
|
123
|
+
(animId: string, prop: string, value: number | string) => {
|
|
124
|
+
if (!domEditSelection) return;
|
|
125
|
+
updateGsapFromProperty(domEditSelection, animId, prop, value);
|
|
126
|
+
},
|
|
127
|
+
[domEditSelection, updateGsapFromProperty],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const handleGsapAddFromProperty = useCallback(
|
|
131
|
+
(animId: string, prop: string) => {
|
|
132
|
+
if (!domEditSelection) return;
|
|
133
|
+
addGsapFromProperty(domEditSelection, animId, prop);
|
|
134
|
+
},
|
|
135
|
+
[domEditSelection, addGsapFromProperty],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const handleGsapRemoveFromProperty = useCallback(
|
|
139
|
+
(animId: string, prop: string) => {
|
|
140
|
+
if (!domEditSelection) return;
|
|
141
|
+
removeGsapFromProperty(domEditSelection, animId, prop);
|
|
142
|
+
},
|
|
143
|
+
[domEditSelection, removeGsapFromProperty],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const handleGsapAddKeyframe = useCallback(
|
|
147
|
+
(animId: string, percentage: number, property: string, value: number | string) => {
|
|
148
|
+
if (!domEditSelection) return;
|
|
149
|
+
addKeyframe(domEditSelection, animId, percentage, property, value);
|
|
150
|
+
},
|
|
151
|
+
[domEditSelection, addKeyframe],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const handleGsapRemoveKeyframe = useCallback(
|
|
155
|
+
(animId: string, percentage: number) => {
|
|
156
|
+
if (!domEditSelection) return;
|
|
157
|
+
removeKeyframe(domEditSelection, animId, percentage);
|
|
158
|
+
},
|
|
159
|
+
[domEditSelection, removeKeyframe],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const handleGsapConvertToKeyframes = useCallback(
|
|
163
|
+
(animId: string) => {
|
|
164
|
+
if (!domEditSelection) return;
|
|
165
|
+
convertToKeyframes(domEditSelection, animId);
|
|
166
|
+
},
|
|
167
|
+
[domEditSelection, convertToKeyframes],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const handleGsapRemoveAllKeyframes = useCallback(
|
|
171
|
+
(animId: string) => {
|
|
172
|
+
if (!domEditSelection) return;
|
|
173
|
+
removeAllKeyframes(domEditSelection, animId);
|
|
174
|
+
},
|
|
175
|
+
[domEditSelection, removeAllKeyframes],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
|
|
179
|
+
if (!domEditSelection) return false;
|
|
180
|
+
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
|
|
181
|
+
if (!withKeyframes) return false;
|
|
182
|
+
removeAllKeyframes(domEditSelection, withKeyframes.id);
|
|
183
|
+
return true;
|
|
184
|
+
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
handleGsapUpdateProperty,
|
|
188
|
+
handleGsapUpdateMeta,
|
|
189
|
+
handleGsapDeleteAnimation,
|
|
190
|
+
handleGsapAddAnimation,
|
|
191
|
+
handleGsapAddProperty,
|
|
192
|
+
handleGsapRemoveProperty,
|
|
193
|
+
handleGsapUpdateFromProperty,
|
|
194
|
+
handleGsapAddFromProperty,
|
|
195
|
+
handleGsapRemoveFromProperty,
|
|
196
|
+
handleGsapAddKeyframe,
|
|
197
|
+
handleGsapRemoveKeyframe,
|
|
198
|
+
handleGsapConvertToKeyframes,
|
|
199
|
+
handleGsapRemoveAllKeyframes,
|
|
200
|
+
handleResetSelectedElementKeyframes,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
5
|
+
|
|
6
|
+
function extractIdFromSelector(selector: string): string | null {
|
|
7
|
+
const match = selector.match(/^#([\w-]+)/);
|
|
8
|
+
return match ? match[1] : null;
|
|
9
|
+
}
|
|
3
10
|
|
|
4
11
|
/** The selected element's identity for matching tweens to it. */
|
|
5
12
|
export interface GsapElementTarget {
|
|
@@ -28,7 +35,7 @@ export function getAnimationsForElement(
|
|
|
28
35
|
);
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
async function fetchParsedAnimations(
|
|
38
|
+
export async function fetchParsedAnimations(
|
|
32
39
|
projectId: string,
|
|
33
40
|
sourceFile: string,
|
|
34
41
|
): Promise<ParsedGsap | null> {
|
|
@@ -47,6 +54,7 @@ export function useGsapAnimationsForElement(
|
|
|
47
54
|
sourceFile: string,
|
|
48
55
|
target: GsapElementTarget | null,
|
|
49
56
|
version: number,
|
|
57
|
+
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
50
58
|
): {
|
|
51
59
|
animations: GsapAnimation[];
|
|
52
60
|
multipleTimelines: boolean;
|
|
@@ -88,9 +96,23 @@ export function useGsapAnimationsForElement(
|
|
|
88
96
|
};
|
|
89
97
|
}, [projectId, sourceFile, version]);
|
|
90
98
|
|
|
99
|
+
// Retry fetch if we have a target but no animations — handles cold-load race
|
|
100
|
+
// where the initial fetch runs before the drilled-down sourceFile is resolved
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!projectId || !target || allAnimations.length > 0) return;
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
|
|
105
|
+
if (parsed && parsed.animations.length > 0) {
|
|
106
|
+
setAllAnimations(parsed.animations);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}, 800);
|
|
110
|
+
return () => clearTimeout(timer);
|
|
111
|
+
}, [projectId, sourceFile, target, allAnimations.length]);
|
|
112
|
+
|
|
91
113
|
const targetId = target?.id ?? null;
|
|
92
114
|
const targetSelector = target?.selector ?? null;
|
|
93
|
-
const
|
|
115
|
+
const rawAnimations = useMemo(
|
|
94
116
|
() =>
|
|
95
117
|
targetId || targetSelector
|
|
96
118
|
? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
|
|
@@ -98,6 +120,76 @@ export function useGsapAnimationsForElement(
|
|
|
98
120
|
[allAnimations, targetId, targetSelector],
|
|
99
121
|
);
|
|
100
122
|
|
|
123
|
+
const animations = useMemo(() => {
|
|
124
|
+
const iframe = iframeRef?.current;
|
|
125
|
+
let result = rawAnimations;
|
|
126
|
+
|
|
127
|
+
// Enrich animations with unresolved keyframes from runtime
|
|
128
|
+
if (iframe) {
|
|
129
|
+
result = result.map((anim) => {
|
|
130
|
+
if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim;
|
|
131
|
+
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
|
132
|
+
if (!runtime) return anim;
|
|
133
|
+
return {
|
|
134
|
+
...anim,
|
|
135
|
+
keyframes: {
|
|
136
|
+
format: "percentage" as const,
|
|
137
|
+
keyframes: runtime.keyframes,
|
|
138
|
+
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Match unresolved-selector animations from the parser to runtime tweens
|
|
145
|
+
// targeting this element. This handles fully dynamic code (loop with variable selector).
|
|
146
|
+
if (iframe && targetId && result.length === 0) {
|
|
147
|
+
const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector);
|
|
148
|
+
if (unresolvedAnims.length > 0) {
|
|
149
|
+
const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`);
|
|
150
|
+
if (runtimeData) {
|
|
151
|
+
const scanned = scanAllRuntimeKeyframes(iframe);
|
|
152
|
+
const runtimeEntry = scanned.get(targetId);
|
|
153
|
+
if (runtimeEntry) {
|
|
154
|
+
// Find which unresolved animation index matches this element
|
|
155
|
+
// by correlating parser order with runtime tween order
|
|
156
|
+
const runtimeIds = Array.from(scanned.keys());
|
|
157
|
+
const runtimeIndex = runtimeIds.indexOf(targetId);
|
|
158
|
+
const matchedAnim =
|
|
159
|
+
runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length
|
|
160
|
+
? unresolvedAnims[runtimeIndex]
|
|
161
|
+
: unresolvedAnims[0];
|
|
162
|
+
if (matchedAnim) {
|
|
163
|
+
result = [
|
|
164
|
+
{
|
|
165
|
+
...matchedAnim,
|
|
166
|
+
targetSelector: `#${targetId}`,
|
|
167
|
+
keyframes: {
|
|
168
|
+
format: "percentage" as const,
|
|
169
|
+
keyframes: runtimeEntry.keyframes,
|
|
170
|
+
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}, [rawAnimations, allAnimations, iframeRef, targetId]);
|
|
182
|
+
|
|
183
|
+
// Populate keyframe cache for the selected element.
|
|
184
|
+
// Key format must match timeline element keys: "sourceFile#domId".
|
|
185
|
+
const elementId = target?.id ?? null;
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!elementId) return;
|
|
188
|
+
const { setKeyframeCache } = usePlayerStore.getState();
|
|
189
|
+
const withKeyframes = animations.find((a) => a.keyframes);
|
|
190
|
+
setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
|
|
191
|
+
}, [elementId, sourceFile, animations]);
|
|
192
|
+
|
|
101
193
|
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
|
102
194
|
}
|
|
103
195
|
|
|
@@ -106,3 +198,82 @@ export function useGsapCacheVersion() {
|
|
|
106
198
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
107
199
|
return { version, bump };
|
|
108
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Fetch GSAP animations for a file and populate the keyframe cache for all
|
|
204
|
+
* elements. Called from the Timeline component so diamonds show without
|
|
205
|
+
* requiring a selection.
|
|
206
|
+
*/
|
|
207
|
+
export function usePopulateKeyframeCacheForFile(
|
|
208
|
+
projectId: string | null,
|
|
209
|
+
sourceFile: string,
|
|
210
|
+
version: number,
|
|
211
|
+
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
212
|
+
): void {
|
|
213
|
+
const lastFetchKeyRef = useRef("");
|
|
214
|
+
|
|
215
|
+
const runtimeScanDoneRef = useRef("");
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
|
219
|
+
if (fetchKey === lastFetchKeyRef.current) return;
|
|
220
|
+
lastFetchKeyRef.current = fetchKey;
|
|
221
|
+
runtimeScanDoneRef.current = "";
|
|
222
|
+
if (!projectId) return;
|
|
223
|
+
|
|
224
|
+
const sf = sourceFile;
|
|
225
|
+
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
226
|
+
if (!parsed) return;
|
|
227
|
+
const { setKeyframeCache } = usePlayerStore.getState();
|
|
228
|
+
for (const anim of parsed.animations) {
|
|
229
|
+
const id = extractIdFromSelector(anim.targetSelector);
|
|
230
|
+
if (!id || !anim.keyframes) continue;
|
|
231
|
+
setKeyframeCache(`${sf}#${id}`, anim.keyframes);
|
|
232
|
+
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
|
233
|
+
}
|
|
234
|
+
runtimeScanDoneRef.current = fetchKey;
|
|
235
|
+
});
|
|
236
|
+
}, [projectId, sourceFile, version]);
|
|
237
|
+
|
|
238
|
+
// Separate effect for runtime keyframe discovery — polls until the iframe
|
|
239
|
+
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (!projectId) return;
|
|
242
|
+
const sf = sourceFile;
|
|
243
|
+
|
|
244
|
+
let attempts = 0;
|
|
245
|
+
const maxAttempts = 10;
|
|
246
|
+
|
|
247
|
+
const tryRuntimeScan = () => {
|
|
248
|
+
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
|
249
|
+
const iframe = iframeRef?.current;
|
|
250
|
+
if (!iframe) return false;
|
|
251
|
+
const scanned = scanAllRuntimeKeyframes(iframe);
|
|
252
|
+
if (scanned.size === 0) return false;
|
|
253
|
+
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
|
254
|
+
for (const [id, data] of scanned) {
|
|
255
|
+
const cacheKey = `${sf}#${id}`;
|
|
256
|
+
const fallbackKey = `index.html#${id}`;
|
|
257
|
+
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue;
|
|
258
|
+
const entry = {
|
|
259
|
+
format: "percentage" as const,
|
|
260
|
+
keyframes: data.keyframes,
|
|
261
|
+
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
262
|
+
};
|
|
263
|
+
setKeyframeCache(cacheKey, entry);
|
|
264
|
+
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
|
265
|
+
}
|
|
266
|
+
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
|
267
|
+
return true;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (tryRuntimeScan()) return;
|
|
271
|
+
|
|
272
|
+
const interval = setInterval(() => {
|
|
273
|
+
attempts++;
|
|
274
|
+
if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval);
|
|
275
|
+
}, 500);
|
|
276
|
+
|
|
277
|
+
return () => clearInterval(interval);
|
|
278
|
+
}, [projectId, sourceFile, version, iframeRef]);
|
|
279
|
+
}
|
|
@@ -466,10 +466,103 @@ export function useTimelineEditing({
|
|
|
466
466
|
[showToast],
|
|
467
467
|
);
|
|
468
468
|
|
|
469
|
+
const handleTimelineElementSplit = useCallback(
|
|
470
|
+
async (element: TimelineElement, splitTime: number) => {
|
|
471
|
+
const pid = projectIdRef.current;
|
|
472
|
+
if (!pid) return;
|
|
473
|
+
|
|
474
|
+
const splittableTags = new Set(["video", "audio", "img"]);
|
|
475
|
+
if (
|
|
476
|
+
element.timelineLocked ||
|
|
477
|
+
element.timingSource === "implicit" ||
|
|
478
|
+
element.compositionSrc ||
|
|
479
|
+
!splittableTags.has(element.tag) ||
|
|
480
|
+
!element.duration ||
|
|
481
|
+
!Number.isFinite(element.duration)
|
|
482
|
+
) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (splitTime <= element.start || splitTime >= element.start + element.duration) {
|
|
487
|
+
showToast("Playhead must be inside the clip to split.", "error");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const patchTarget = buildPatchTarget(element);
|
|
492
|
+
if (!patchTarget) {
|
|
493
|
+
showToast("Clip is missing a patchable target.", "error");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
498
|
+
try {
|
|
499
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
500
|
+
const existingIds = collectHtmlIds(originalContent);
|
|
501
|
+
const baseId = element.domId || "clip";
|
|
502
|
+
let newId = `${baseId}-split`;
|
|
503
|
+
let suffix = 2;
|
|
504
|
+
while (existingIds.includes(newId)) {
|
|
505
|
+
newId = `${baseId}-split-${suffix++}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const response = await fetch(
|
|
509
|
+
`/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
|
510
|
+
{
|
|
511
|
+
method: "POST",
|
|
512
|
+
headers: { "Content-Type": "application/json" },
|
|
513
|
+
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
throw new Error("Split request failed");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const data = (await response.json()) as {
|
|
521
|
+
ok?: boolean;
|
|
522
|
+
changed?: boolean;
|
|
523
|
+
content?: string;
|
|
524
|
+
};
|
|
525
|
+
if (!data.ok || !data.changed) {
|
|
526
|
+
showToast("Failed to split clip — playhead may be outside the clip.", "error");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const patchedContent = typeof data.content === "string" ? data.content : originalContent;
|
|
531
|
+
|
|
532
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
533
|
+
await saveProjectFilesWithHistory({
|
|
534
|
+
projectId: pid,
|
|
535
|
+
label: "Split timeline clip",
|
|
536
|
+
kind: "timeline",
|
|
537
|
+
files: { [targetPath]: patchedContent },
|
|
538
|
+
readFile: async () => originalContent,
|
|
539
|
+
writeFile: writeProjectFile,
|
|
540
|
+
recordEdit,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
reloadPreview();
|
|
544
|
+
const label = getTimelineElementLabel(element);
|
|
545
|
+
showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info");
|
|
546
|
+
} catch (error) {
|
|
547
|
+
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
|
|
548
|
+
showToast(message, "error");
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
[
|
|
552
|
+
activeCompPath,
|
|
553
|
+
recordEdit,
|
|
554
|
+
showToast,
|
|
555
|
+
writeProjectFile,
|
|
556
|
+
domEditSaveTimestampRef,
|
|
557
|
+
reloadPreview,
|
|
558
|
+
],
|
|
559
|
+
);
|
|
560
|
+
|
|
469
561
|
return {
|
|
470
562
|
handleTimelineElementMove,
|
|
471
563
|
handleTimelineElementResize,
|
|
472
564
|
handleTimelineElementDelete,
|
|
565
|
+
handleTimelineElementSplit,
|
|
473
566
|
handleTimelineAssetDrop,
|
|
474
567
|
handleTimelineFileDrop,
|
|
475
568
|
handleBlockedTimelineEdit,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
Camera as PhCamera,
|
|
21
21
|
ArrowClockwise,
|
|
22
22
|
Gear,
|
|
23
|
+
Scissors as PhScissors,
|
|
23
24
|
} from "@phosphor-icons/react";
|
|
24
25
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
25
26
|
|
|
@@ -55,3 +56,4 @@ export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
|
55
56
|
export const Camera = makeIcon(PhCamera);
|
|
56
57
|
export const RotateCw = makeIcon(ArrowClockwise);
|
|
57
58
|
export const Settings = makeIcon(Gear);
|
|
59
|
+
export const Scissors = makeIcon(PhScissors);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
|
|
4
|
+
interface ClipContextMenuProps {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
element: TimelineElement;
|
|
8
|
+
currentTime: number;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onSplit: (element: TimelineElement, splitTime: number) => void;
|
|
11
|
+
onDelete: (element: TimelineElement) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ClipContextMenu = memo(function ClipContextMenu({
|
|
15
|
+
x,
|
|
16
|
+
y,
|
|
17
|
+
element,
|
|
18
|
+
currentTime,
|
|
19
|
+
onClose,
|
|
20
|
+
onSplit,
|
|
21
|
+
onDelete,
|
|
22
|
+
}: ClipContextMenuProps) {
|
|
23
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
|
|
25
|
+
const dismiss = useCallback(
|
|
26
|
+
(e: MouseEvent | KeyboardEvent) => {
|
|
27
|
+
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
|
28
|
+
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
|
|
29
|
+
onClose();
|
|
30
|
+
},
|
|
31
|
+
[onClose],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
document.addEventListener("mousedown", dismiss);
|
|
36
|
+
document.addEventListener("keydown", dismiss);
|
|
37
|
+
return () => {
|
|
38
|
+
document.removeEventListener("mousedown", dismiss);
|
|
39
|
+
document.removeEventListener("keydown", dismiss);
|
|
40
|
+
};
|
|
41
|
+
}, [dismiss]);
|
|
42
|
+
|
|
43
|
+
const adjustedX = Math.min(x, window.innerWidth - 200);
|
|
44
|
+
const adjustedY = Math.min(y, window.innerHeight - 200);
|
|
45
|
+
|
|
46
|
+
const isSplittable = ["video", "audio", "img"].includes(element.tag);
|
|
47
|
+
const canSplit =
|
|
48
|
+
isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
|
|
49
|
+
|
|
50
|
+
const splitLabel = !isSplittable
|
|
51
|
+
? null
|
|
52
|
+
: canSplit
|
|
53
|
+
? `Split at ${currentTime.toFixed(2)}s`
|
|
54
|
+
: "Split (move playhead inside clip)";
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
ref={menuRef}
|
|
59
|
+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
60
|
+
style={{ left: adjustedX, top: adjustedY }}
|
|
61
|
+
>
|
|
62
|
+
{splitLabel && (
|
|
63
|
+
<>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
|
|
67
|
+
canSplit
|
|
68
|
+
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
69
|
+
: "text-neutral-600 cursor-not-allowed"
|
|
70
|
+
}`}
|
|
71
|
+
disabled={!canSplit}
|
|
72
|
+
onClick={() => {
|
|
73
|
+
if (canSplit) {
|
|
74
|
+
onSplit(element, currentTime);
|
|
75
|
+
onClose();
|
|
76
|
+
}
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<span>{splitLabel}</span>
|
|
80
|
+
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
81
|
+
</button>
|
|
82
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
89
|
+
onClick={() => {
|
|
90
|
+
onDelete(element);
|
|
91
|
+
onClose();
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<span>Delete</span>
|
|
95
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
});
|