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