@hyperframes/studio 0.6.110 → 0.6.111
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-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
|
@@ -26,6 +26,10 @@ interface UseElementLifecycleOpsParams {
|
|
|
26
26
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
27
27
|
reloadPreview: () => void;
|
|
28
28
|
clearDomSelection: () => void;
|
|
29
|
+
/** Route delete through SDK when session resolves the hf-id; returns true if handled. */
|
|
30
|
+
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
|
31
|
+
/** Resync the SDK session after a server-fallback delete. */
|
|
32
|
+
forceReloadSdkSession?: () => void;
|
|
29
33
|
commitPositionPatchToHtml: (
|
|
30
34
|
selection: DomEditSelection,
|
|
31
35
|
patches: PatchOperation[],
|
|
@@ -44,6 +48,8 @@ export function useElementLifecycleOps({
|
|
|
44
48
|
projectIdRef,
|
|
45
49
|
reloadPreview,
|
|
46
50
|
clearDomSelection,
|
|
51
|
+
onTrySdkDelete,
|
|
52
|
+
forceReloadSdkSession,
|
|
47
53
|
commitPositionPatchToHtml,
|
|
48
54
|
onElementDeleted,
|
|
49
55
|
}: UseElementLifecycleOpsParams) {
|
|
@@ -74,6 +80,16 @@ export function useElementLifecycleOps({
|
|
|
74
80
|
throw new Error("Selected element has no patchable target");
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
if (onTrySdkDelete && selection.hfId) {
|
|
84
|
+
const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
|
|
85
|
+
if (handled) {
|
|
86
|
+
clearDomSelection();
|
|
87
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
88
|
+
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
domEditSaveTimestampRef.current = Date.now();
|
|
78
94
|
const removeResponse = await fetch(
|
|
79
95
|
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
@@ -93,6 +109,12 @@ export function useElementLifecycleOps({
|
|
|
93
109
|
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
|
|
94
110
|
const patchedContent =
|
|
95
111
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
112
|
+
// ponytail: the server remove-element route (removeElementFromHtml) strips
|
|
113
|
+
// only the element node — it does NOT cascade-remove GSAP tweens targeting
|
|
114
|
+
// it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This
|
|
115
|
+
// fallback runs only when the element isn't in the SDK doc (e.g. runtime-
|
|
116
|
+
// generated / unaddressable), where targeting tweens are unlikely. Upgrade
|
|
117
|
+
// path: cascade in removeElementFromHtml by selector/hf-id to fully match.
|
|
96
118
|
await saveProjectFilesWithHistory({
|
|
97
119
|
projectId: pid,
|
|
98
120
|
label: "Delete element",
|
|
@@ -105,6 +127,9 @@ export function useElementLifecycleOps({
|
|
|
105
127
|
|
|
106
128
|
clearDomSelection();
|
|
107
129
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
130
|
+
// Server wrote the file; resync the stale in-memory SDK doc so a later
|
|
131
|
+
// SDK edit doesn't resurrect the deleted element.
|
|
132
|
+
forceReloadSdkSession?.();
|
|
108
133
|
reloadPreview();
|
|
109
134
|
onElementDeleted?.(selection);
|
|
110
135
|
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
@@ -118,7 +143,9 @@ export function useElementLifecycleOps({
|
|
|
118
143
|
clearDomSelection,
|
|
119
144
|
domEditSaveTimestampRef,
|
|
120
145
|
editHistory.recordEdit,
|
|
146
|
+
onTrySdkDelete,
|
|
121
147
|
onElementDeleted,
|
|
148
|
+
forceReloadSdkSession,
|
|
122
149
|
projectIdRef,
|
|
123
150
|
reloadPreview,
|
|
124
151
|
showToast,
|
|
@@ -126,6 +153,9 @@ export function useElementLifecycleOps({
|
|
|
126
153
|
],
|
|
127
154
|
);
|
|
128
155
|
|
|
156
|
+
// ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml →
|
|
157
|
+
// persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
|
|
158
|
+
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
|
|
129
159
|
const handleDomZIndexReorderCommit = useCallback(
|
|
130
160
|
(
|
|
131
161
|
entries: Array<{
|
|
@@ -2,21 +2,28 @@ import { useCallback } from "react";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { roundTo3 } from "../utils/rounding";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
sdkGsapTweenPersist,
|
|
7
|
+
sdkGsapDeleteAllForSelectorPersist,
|
|
8
|
+
type CutoverDeps,
|
|
9
|
+
} from "../utils/sdkCutover";
|
|
6
10
|
import {
|
|
7
11
|
assignGsapTargetAutoIdIfNeeded,
|
|
8
12
|
ensureElementAddressable,
|
|
9
13
|
} from "./gsapScriptCommitHelpers";
|
|
10
14
|
import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
|
|
11
15
|
|
|
12
|
-
interface
|
|
16
|
+
interface SdkAnimationDeps {
|
|
17
|
+
sdkSession?: Composition | null;
|
|
18
|
+
sdkDeps?: CutoverDeps | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface GsapAnimationOpsParams extends SdkAnimationDeps {
|
|
13
22
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
14
23
|
activeCompPath: string | null;
|
|
15
24
|
commitMutation: CommitMutation;
|
|
16
25
|
commitMutationSafely: SafeGsapCommitMutation;
|
|
17
26
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
18
|
-
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
|
|
19
|
-
sdkSession?: Composition | null;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export function useGsapAnimationOps({
|
|
@@ -26,60 +33,79 @@ export function useGsapAnimationOps({
|
|
|
26
33
|
commitMutationSafely,
|
|
27
34
|
showToast,
|
|
28
35
|
sdkSession,
|
|
36
|
+
sdkDeps,
|
|
29
37
|
}: GsapAnimationOpsParams) {
|
|
30
38
|
const updateGsapMeta = useCallback(
|
|
31
|
-
(
|
|
39
|
+
async (
|
|
32
40
|
selection: DomEditSelection,
|
|
33
41
|
animationId: string,
|
|
34
42
|
updates: { duration?: number; ease?: string; position?: number },
|
|
35
43
|
) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
if (sdkSession && sdkDeps) {
|
|
45
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
46
|
+
const handled = await sdkGsapTweenPersist(
|
|
47
|
+
targetPath,
|
|
48
|
+
{ kind: "set", animationId, properties: updates },
|
|
49
|
+
sdkSession,
|
|
50
|
+
sdkDeps,
|
|
51
|
+
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
|
|
52
|
+
);
|
|
53
|
+
if (handled) return;
|
|
54
|
+
}
|
|
47
55
|
commitMutationSafely(
|
|
48
56
|
selection,
|
|
49
57
|
{ type: "update-meta", animationId, updates },
|
|
50
|
-
{ label: "Edit GSAP animation", coalesceKey:
|
|
58
|
+
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
|
|
51
59
|
);
|
|
52
|
-
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
53
60
|
},
|
|
54
|
-
[commitMutationSafely, sdkSession],
|
|
61
|
+
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
|
55
62
|
);
|
|
56
63
|
|
|
57
64
|
const deleteGsapAnimation = useCallback(
|
|
58
|
-
(selection: DomEditSelection, animationId: string) => {
|
|
59
|
-
|
|
65
|
+
async (selection: DomEditSelection, animationId: string) => {
|
|
66
|
+
if (sdkSession && sdkDeps) {
|
|
67
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
68
|
+
const handled = await sdkGsapTweenPersist(
|
|
69
|
+
targetPath,
|
|
70
|
+
{ kind: "remove", animationId },
|
|
71
|
+
sdkSession,
|
|
72
|
+
sdkDeps,
|
|
73
|
+
{ label: "Delete GSAP animation" },
|
|
74
|
+
);
|
|
75
|
+
if (handled) return;
|
|
76
|
+
}
|
|
60
77
|
commitMutationSafely(
|
|
61
78
|
selection,
|
|
62
79
|
{ type: "delete", animationId, stripStudioEdits: true },
|
|
63
|
-
{ label: "Delete GSAP animation"
|
|
80
|
+
{ label: "Delete GSAP animation" },
|
|
64
81
|
);
|
|
65
|
-
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
66
82
|
},
|
|
67
|
-
[commitMutationSafely, sdkSession],
|
|
83
|
+
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
|
68
84
|
);
|
|
69
85
|
|
|
70
86
|
const deleteAllForSelector = useCallback(
|
|
71
|
-
(selection: DomEditSelection, targetSelector: string) => {
|
|
87
|
+
async (selection: DomEditSelection, targetSelector: string) => {
|
|
88
|
+
if (sdkSession && sdkDeps) {
|
|
89
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
90
|
+
const handled = await sdkGsapDeleteAllForSelectorPersist(
|
|
91
|
+
targetPath,
|
|
92
|
+
targetSelector,
|
|
93
|
+
sdkSession,
|
|
94
|
+
sdkDeps,
|
|
95
|
+
{ label: "Delete all animations for element" },
|
|
96
|
+
);
|
|
97
|
+
if (handled) return;
|
|
98
|
+
}
|
|
72
99
|
void commitMutation(
|
|
73
100
|
selection,
|
|
74
101
|
{ type: "delete-all-for-selector", targetSelector },
|
|
75
102
|
{ label: "Delete all animations for element" },
|
|
76
103
|
);
|
|
77
104
|
},
|
|
78
|
-
[commitMutation],
|
|
105
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
79
106
|
);
|
|
80
107
|
|
|
81
|
-
//
|
|
82
|
-
// adds only a guarded shadow-op construction at the tail.
|
|
108
|
+
// fallow-ignore-next-line complexity
|
|
83
109
|
const addGsapAnimation = useCallback(
|
|
84
110
|
// fallow-ignore-next-line complexity
|
|
85
111
|
async (
|
|
@@ -114,25 +140,28 @@ export function useGsapAnimationOps({
|
|
|
114
140
|
fromTo: { x: 0, y: 0, opacity: 1 },
|
|
115
141
|
};
|
|
116
142
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
:
|
|
143
|
+
// Skip SDK path when an id was just assigned server-side (autoId): the
|
|
144
|
+
// SDK session hasn't reloaded that write yet, so persisting its
|
|
145
|
+
// serialization would clobber the new id — let the server add the tween
|
|
146
|
+
// atomically with the id it wrote.
|
|
147
|
+
if (!autoId && selection.hfId && sdkSession && sdkDeps) {
|
|
148
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
149
|
+
const spec = {
|
|
150
|
+
method,
|
|
151
|
+
position,
|
|
152
|
+
...(method !== "set" ? { duration, ease: "power2.out" as const } : {}),
|
|
153
|
+
properties: toDefaults[method] ?? { opacity: 1 },
|
|
154
|
+
...(method === "fromTo" ? { fromProperties: { opacity: 0 } } : {}),
|
|
155
|
+
};
|
|
156
|
+
const handled = await sdkGsapTweenPersist(
|
|
157
|
+
targetPath,
|
|
158
|
+
{ kind: "add", target: selection.hfId, spec },
|
|
159
|
+
sdkSession,
|
|
160
|
+
sdkDeps,
|
|
161
|
+
{ label: `Add GSAP ${method} animation` },
|
|
162
|
+
);
|
|
163
|
+
if (handled) return;
|
|
164
|
+
}
|
|
136
165
|
|
|
137
166
|
await commitMutation(
|
|
138
167
|
selection,
|
|
@@ -146,12 +175,10 @@ export function useGsapAnimationOps({
|
|
|
146
175
|
properties: toDefaults[method] ?? { opacity: 1 },
|
|
147
176
|
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
|
|
148
177
|
},
|
|
149
|
-
{ label: `Add GSAP ${method} animation
|
|
178
|
+
{ label: `Add GSAP ${method} animation` },
|
|
150
179
|
);
|
|
151
|
-
|
|
152
|
-
if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
153
180
|
},
|
|
154
|
-
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
|
|
181
|
+
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
|
|
155
182
|
);
|
|
156
183
|
|
|
157
184
|
return {
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
5
|
import { executeOptimistic } from "../utils/optimisticUpdate";
|
|
6
|
+
import {
|
|
7
|
+
sdkGsapKeyframePersist,
|
|
8
|
+
sdkGsapRemoveKeyframePersist,
|
|
9
|
+
sdkGsapRemoveAllKeyframesPersist,
|
|
10
|
+
sdkGsapConvertToKeyframesPersist,
|
|
11
|
+
type CutoverDeps,
|
|
12
|
+
} from "../utils/sdkCutover";
|
|
6
13
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
7
14
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
8
15
|
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
|
@@ -31,7 +38,12 @@ function executeOptimisticKeyframeCacheUpdate(options: {
|
|
|
31
38
|
});
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
interface
|
|
41
|
+
interface SdkKeyframeDeps {
|
|
42
|
+
sdkSession?: Composition | null;
|
|
43
|
+
sdkDeps?: CutoverDeps | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
|
|
35
47
|
activeCompPath: string | null;
|
|
36
48
|
commitMutation: CommitMutation;
|
|
37
49
|
commitMutationSafely: SafeGsapCommitMutation;
|
|
@@ -43,6 +55,8 @@ export function useGsapKeyframeOps({
|
|
|
43
55
|
commitMutation,
|
|
44
56
|
commitMutationSafely,
|
|
45
57
|
trackGsapSaveFailure,
|
|
58
|
+
sdkSession,
|
|
59
|
+
sdkDeps,
|
|
46
60
|
}: GsapKeyframeOpsParams) {
|
|
47
61
|
const addKeyframe = useCallback(
|
|
48
62
|
(
|
|
@@ -59,111 +73,181 @@ export function useGsapKeyframeOps({
|
|
|
59
73
|
percentage,
|
|
60
74
|
properties: { [property]: value },
|
|
61
75
|
};
|
|
62
|
-
// Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
|
|
63
|
-
const shadowKeyframeOp: ShadowKeyframeOp = {
|
|
64
|
-
kind: "add",
|
|
65
|
-
animationId,
|
|
66
|
-
percentage,
|
|
67
|
-
properties: { [property]: value },
|
|
68
|
-
};
|
|
69
76
|
void executeOptimisticKeyframeCacheUpdate({
|
|
70
77
|
sourceFile,
|
|
71
78
|
elementId: selection.id,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// Merge into an existing keyframe at this percentage rather than
|
|
80
|
+
// appending a duplicate — matches addKeyframeToScript, which writes one
|
|
81
|
+
// keyframe per percentage (merging properties).
|
|
82
|
+
apply: (prev) => {
|
|
83
|
+
// Match addKeyframeToScript's merge tolerance (PCT_TOLERANCE = 2 in
|
|
84
|
+
// gsapWriterAcorn): a keyframe added within 2% of an existing one
|
|
85
|
+
// merges on disk, so the optimistic cache must merge it too — else the
|
|
86
|
+
// UI shows a phantom keyframe that vanishes on the next reload.
|
|
87
|
+
const idx = prev.keyframes.findIndex(
|
|
88
|
+
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) <= 2,
|
|
89
|
+
);
|
|
90
|
+
if (idx >= 0) {
|
|
91
|
+
const keyframes = prev.keyframes.slice();
|
|
92
|
+
keyframes[idx] = {
|
|
93
|
+
...keyframes[idx],
|
|
94
|
+
properties: { ...keyframes[idx].properties, [property]: value },
|
|
95
|
+
};
|
|
96
|
+
return { ...prev, keyframes };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
...prev,
|
|
100
|
+
keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
|
|
101
|
+
(a, b) => a.percentage - b.percentage,
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
persist: async () => {
|
|
106
|
+
if (sdkSession && sdkDeps) {
|
|
107
|
+
const handled = await sdkGsapKeyframePersist(
|
|
108
|
+
sourceFile,
|
|
109
|
+
animationId,
|
|
110
|
+
percentage,
|
|
111
|
+
{ [property]: value },
|
|
112
|
+
sdkSession,
|
|
113
|
+
sdkDeps,
|
|
114
|
+
{
|
|
115
|
+
label: `Add keyframe at ${percentage}%`,
|
|
116
|
+
coalesceKey: `gsap:${animationId}:kf:${percentage}`,
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
if (handled) return;
|
|
120
|
+
}
|
|
121
|
+
await commitMutation(selection, mutation, {
|
|
80
122
|
label: `Add keyframe at ${percentage}%`,
|
|
81
123
|
softReload: true,
|
|
82
|
-
|
|
83
|
-
|
|
124
|
+
});
|
|
125
|
+
},
|
|
84
126
|
}).catch((error) => {
|
|
85
127
|
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
|
|
86
128
|
});
|
|
87
129
|
},
|
|
88
|
-
[activeCompPath, commitMutation, trackGsapSaveFailure],
|
|
130
|
+
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
|
|
89
131
|
);
|
|
90
132
|
|
|
91
133
|
const addKeyframeBatch = useCallback(
|
|
92
|
-
(
|
|
134
|
+
async (
|
|
93
135
|
selection: DomEditSelection,
|
|
94
136
|
animationId: string,
|
|
95
137
|
percentage: number,
|
|
96
138
|
properties: Record<string, number | string>,
|
|
97
139
|
) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
140
|
+
if (sdkSession && sdkDeps) {
|
|
141
|
+
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
|
142
|
+
const handled = await sdkGsapKeyframePersist(
|
|
143
|
+
sourceFile,
|
|
144
|
+
animationId,
|
|
145
|
+
percentage,
|
|
146
|
+
properties,
|
|
147
|
+
sdkSession,
|
|
148
|
+
sdkDeps,
|
|
149
|
+
{ label: `Add keyframe at ${percentage}%` },
|
|
150
|
+
);
|
|
151
|
+
if (handled) return;
|
|
152
|
+
}
|
|
104
153
|
return commitMutation(
|
|
105
154
|
selection,
|
|
106
155
|
{ type: "add-keyframe", animationId, percentage, properties },
|
|
107
|
-
{ label: `Add keyframe at ${percentage}%`, softReload: true
|
|
156
|
+
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
|
108
157
|
);
|
|
109
158
|
},
|
|
110
|
-
[commitMutation],
|
|
159
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
111
160
|
);
|
|
112
161
|
|
|
113
162
|
const removeKeyframe = useCallback(
|
|
114
163
|
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
|
115
164
|
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
|
116
165
|
const mutation = { type: "remove-keyframe", animationId, percentage };
|
|
117
|
-
// Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
|
|
118
|
-
// so the runner resolves percentage → keyframeIndex against the pre-op
|
|
119
|
-
// script and no-ops on ambiguity (duplicate-percentage keyframes).
|
|
120
|
-
const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
|
|
121
166
|
void executeOptimisticKeyframeCacheUpdate({
|
|
122
167
|
sourceFile,
|
|
123
168
|
elementId: selection.id,
|
|
124
169
|
apply: (prev) => ({
|
|
125
170
|
...prev,
|
|
171
|
+
// Match the writer's removal tolerance (PCT_TOLERANCE = 2 in
|
|
172
|
+
// gsapWriterAcorn): removing at e.g. 49% drops a keyframe at 50% on
|
|
173
|
+
// disk, so the optimistic cache must drop it too — else the stranded
|
|
174
|
+
// entry is a phantom that vanishes on the next reload (mirror of the
|
|
175
|
+
// add-path tolerance fix).
|
|
126
176
|
keyframes: prev.keyframes.filter(
|
|
127
|
-
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) >
|
|
177
|
+
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 2,
|
|
128
178
|
),
|
|
129
179
|
}),
|
|
130
|
-
persist: () =>
|
|
131
|
-
|
|
180
|
+
persist: async () => {
|
|
181
|
+
if (sdkSession && sdkDeps) {
|
|
182
|
+
const handled = await sdkGsapRemoveKeyframePersist(
|
|
183
|
+
sourceFile,
|
|
184
|
+
animationId,
|
|
185
|
+
percentage,
|
|
186
|
+
sdkSession,
|
|
187
|
+
sdkDeps,
|
|
188
|
+
{ label: `Remove keyframe at ${percentage}%` },
|
|
189
|
+
);
|
|
190
|
+
if (handled) return;
|
|
191
|
+
}
|
|
192
|
+
await commitMutation(selection, mutation, {
|
|
132
193
|
label: `Remove keyframe at ${percentage}%`,
|
|
133
194
|
softReload: true,
|
|
134
|
-
|
|
135
|
-
|
|
195
|
+
});
|
|
196
|
+
},
|
|
136
197
|
}).catch((error) => {
|
|
137
198
|
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
|
|
138
199
|
});
|
|
139
200
|
},
|
|
140
|
-
[activeCompPath, commitMutation, trackGsapSaveFailure],
|
|
201
|
+
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
|
|
141
202
|
);
|
|
142
203
|
|
|
143
204
|
const convertToKeyframes = useCallback(
|
|
144
|
-
(
|
|
205
|
+
async (
|
|
145
206
|
selection: DomEditSelection,
|
|
146
207
|
animationId: string,
|
|
147
208
|
resolvedFromValues?: Record<string, number | string>,
|
|
148
209
|
) => {
|
|
210
|
+
if (sdkSession && sdkDeps) {
|
|
211
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
212
|
+
const handled = await sdkGsapConvertToKeyframesPersist(
|
|
213
|
+
targetPath,
|
|
214
|
+
animationId,
|
|
215
|
+
resolvedFromValues,
|
|
216
|
+
sdkSession,
|
|
217
|
+
sdkDeps,
|
|
218
|
+
{ label: "Convert to keyframes" },
|
|
219
|
+
);
|
|
220
|
+
if (handled) return;
|
|
221
|
+
}
|
|
149
222
|
return commitMutation(
|
|
150
223
|
selection,
|
|
151
224
|
{ type: "convert-to-keyframes", animationId, resolvedFromValues },
|
|
152
225
|
{ label: "Convert to keyframes" },
|
|
153
226
|
);
|
|
154
227
|
},
|
|
155
|
-
[commitMutation],
|
|
228
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
156
229
|
);
|
|
157
230
|
|
|
158
231
|
const removeAllKeyframes = useCallback(
|
|
159
|
-
(selection: DomEditSelection, animationId: string) => {
|
|
232
|
+
async (selection: DomEditSelection, animationId: string) => {
|
|
233
|
+
if (sdkSession && sdkDeps) {
|
|
234
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
235
|
+
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
|
236
|
+
targetPath,
|
|
237
|
+
animationId,
|
|
238
|
+
sdkSession,
|
|
239
|
+
sdkDeps,
|
|
240
|
+
{ label: "Remove all keyframes" },
|
|
241
|
+
);
|
|
242
|
+
if (handled) return;
|
|
243
|
+
}
|
|
160
244
|
commitMutationSafely(
|
|
161
245
|
selection,
|
|
162
246
|
{ type: "remove-all-keyframes", animationId },
|
|
163
247
|
{ label: "Remove all keyframes", softReload: true },
|
|
164
248
|
);
|
|
165
249
|
},
|
|
166
|
-
[commitMutationSafely],
|
|
250
|
+
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
|
167
251
|
);
|
|
168
252
|
|
|
169
253
|
const commitKeyframeAtTime = useCallback(
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
4
|
+
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
|
5
|
+
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
6
|
+
import { mergeTweenProperties } from "./useGsapPropertyDebounce";
|
|
7
|
+
import { extractGsapScriptText } from "../utils/gsapSoftReload";
|
|
8
|
+
|
|
9
|
+
const HTML = `<!DOCTYPE html><html><head></head><body>
|
|
10
|
+
<div id="box" data-hf-id="hf-box" style="opacity:1"></div>
|
|
11
|
+
<script data-hf-gsap>
|
|
12
|
+
const tl = gsap.timeline({ paused: true });
|
|
13
|
+
window.__timelines = { main: tl };
|
|
14
|
+
tl.to('#box', { duration: 1, x: 100, y: 50, opacity: 1 });
|
|
15
|
+
</script>
|
|
16
|
+
</body></html>`;
|
|
17
|
+
|
|
18
|
+
const FROMTO_HTML = `<!DOCTYPE html><html><head></head><body>
|
|
19
|
+
<div id="box" data-hf-id="hf-box" style="opacity:1"></div>
|
|
20
|
+
<script data-hf-gsap>
|
|
21
|
+
const tl = gsap.timeline({ paused: true });
|
|
22
|
+
window.__timelines = { main: tl };
|
|
23
|
+
tl.fromTo('#box', { x: 0, y: 0 }, { duration: 1, x: 100, y: 50 });
|
|
24
|
+
</script>
|
|
25
|
+
</body></html>`;
|
|
26
|
+
|
|
27
|
+
function tweenProps(comp: { serialize(): string }) {
|
|
28
|
+
const parsed = parseGsapScriptAcorn(extractGsapScriptText(comp.serialize()) ?? "");
|
|
29
|
+
const anim = parsed.animations[0];
|
|
30
|
+
return { id: anim?.id, properties: anim?.properties, fromProperties: anim?.fromProperties };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("setGsapTween replace semantics (finding #1)", () => {
|
|
34
|
+
it("REGRESSION: a single-key set drops the tween's other animated props", async () => {
|
|
35
|
+
// This documents the bug the merge fixes: setGsapTween REPLACES the property
|
|
36
|
+
// set, so sending only the edited key loses the siblings.
|
|
37
|
+
const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
|
|
38
|
+
const id = tweenProps(comp).id ?? "";
|
|
39
|
+
comp.setGsapTween(id, { properties: { x: 200 } });
|
|
40
|
+
const after = tweenProps(comp);
|
|
41
|
+
expect(after.properties).toEqual({ x: 200 });
|
|
42
|
+
expect(after.properties).not.toHaveProperty("y");
|
|
43
|
+
expect(after.properties).not.toHaveProperty("opacity");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("mergeTweenProperties (finding #1)", () => {
|
|
48
|
+
it("editing x preserves y and opacity through a real SDK write", async () => {
|
|
49
|
+
const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
|
|
50
|
+
const id = tweenProps(comp).id ?? "";
|
|
51
|
+
// Mirror the send site: merge the single edited prop into the existing set.
|
|
52
|
+
const merged = mergeTweenProperties(comp, id, { x: 200 }, "to");
|
|
53
|
+
expect(merged).toEqual({ x: 200, y: 50, opacity: 1 });
|
|
54
|
+
comp.setGsapTween(id, { properties: merged });
|
|
55
|
+
const after = tweenProps(comp);
|
|
56
|
+
expect(after.properties).toMatchObject({ x: 200, y: 50, opacity: 1 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("editing a from-property preserves the other from-properties", async () => {
|
|
60
|
+
const comp = await openComposition(FROMTO_HTML, { persist: createMemoryAdapter() });
|
|
61
|
+
const id = tweenProps(comp).id ?? "";
|
|
62
|
+
const merged = mergeTweenProperties(comp, id, { x: 25 }, "from");
|
|
63
|
+
expect(merged).toEqual({ x: 25, y: 0 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns the single edit unchanged when the tween id is unknown", async () => {
|
|
67
|
+
const comp = await openComposition(HTML, { persist: createMemoryAdapter() });
|
|
68
|
+
expect(mergeTweenProperties(comp, "no-such-id", { x: 5 }, "to")).toEqual({ x: 5 });
|
|
69
|
+
});
|
|
70
|
+
});
|