@hyperframes/studio 0.7.92 → 0.7.93
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-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { describe, expect, it, beforeEach } from "vitest";
|
|
3
|
+
import { describe, expect, it, beforeEach, vi } from "vitest";
|
|
4
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
6
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
commitStaticGsapRotation,
|
|
10
10
|
commitStaticGsapSize,
|
|
11
11
|
findExistingPositionWrite,
|
|
12
|
+
materializeIfDynamic,
|
|
12
13
|
parkPlayheadOnKeyframe,
|
|
13
14
|
type GsapDragCommitCallbacks,
|
|
14
15
|
} from "./gsapDragCommit";
|
|
@@ -27,6 +28,43 @@ const selection = (): DomEditSelection =>
|
|
|
27
28
|
},
|
|
28
29
|
}) as unknown as DomEditSelection;
|
|
29
30
|
|
|
31
|
+
function selectorlessSelection(): DomEditSelection {
|
|
32
|
+
return {
|
|
33
|
+
selector: ".shared",
|
|
34
|
+
element: document.createElement("div"),
|
|
35
|
+
} as unknown as DomEditSelection;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("lower GSAP commit helpers fail closed", () => {
|
|
39
|
+
it("rejects instead of silently dropping a static position write without a stable selector", async () => {
|
|
40
|
+
const commitMutation = vi.fn();
|
|
41
|
+
await expect(
|
|
42
|
+
commitStaticGsapPosition(
|
|
43
|
+
selectorlessSelection(),
|
|
44
|
+
{ x: 10, y: 20 },
|
|
45
|
+
{ x: 0, y: 0 },
|
|
46
|
+
".shared",
|
|
47
|
+
null,
|
|
48
|
+
{ commitMutation },
|
|
49
|
+
),
|
|
50
|
+
).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "no-selector" });
|
|
51
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects runtime-dynamic materialization instead of rewriting source during a gesture", async () => {
|
|
55
|
+
const commitMutation = vi.fn();
|
|
56
|
+
await expect(
|
|
57
|
+
materializeIfDynamic(
|
|
58
|
+
{ ...flatTween(), hasUnresolvedKeyframes: true },
|
|
59
|
+
null,
|
|
60
|
+
commitMutation,
|
|
61
|
+
selection(),
|
|
62
|
+
),
|
|
63
|
+
).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "source-uneditable" });
|
|
64
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
30
68
|
const flatTween = (): GsapAnimation =>
|
|
31
69
|
({
|
|
32
70
|
id: "#puck-a-to",
|
|
@@ -9,14 +9,14 @@ import {
|
|
|
9
9
|
STUDIO_ORIGINAL_HEIGHT_ATTR,
|
|
10
10
|
} from "../components/editor/manualEditsTypes";
|
|
11
11
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
12
|
-
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
13
12
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
14
13
|
import { roundTo3 } from "../utils/rounding";
|
|
15
|
-
import { computeElementPercentage,
|
|
14
|
+
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
|
16
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
17
16
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
18
17
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
19
18
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
19
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
20
20
|
export {
|
|
21
21
|
findExistingPositionWrite,
|
|
22
22
|
findRotationSetAnimation,
|
|
@@ -87,7 +87,7 @@ async function replaceKeyframedPositionHold(
|
|
|
87
87
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
88
88
|
): Promise<void> {
|
|
89
89
|
const target = newTweenTarget(selection);
|
|
90
|
-
if (!target)
|
|
90
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
91
91
|
const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
|
|
92
92
|
await commit(
|
|
93
93
|
selection,
|
|
@@ -130,40 +130,12 @@ export async function materializeIfDynamic(
|
|
|
130
130
|
selection: DomEditSelection,
|
|
131
131
|
): Promise<string | void> {
|
|
132
132
|
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
keyframes: data.keyframes,
|
|
140
|
-
easeEach: data.easeEach,
|
|
141
|
-
}));
|
|
142
|
-
await commitMutation(
|
|
143
|
-
selection,
|
|
144
|
-
{
|
|
145
|
-
type: "materialize-keyframes",
|
|
146
|
-
animationId: anim.id,
|
|
147
|
-
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
|
|
148
|
-
allElements,
|
|
149
|
-
},
|
|
150
|
-
{ label: "Unroll dynamic animations", skipReload: true },
|
|
151
|
-
);
|
|
152
|
-
return `${anim.targetSelector}-to-0`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
|
156
|
-
if (!runtime || runtime.keyframes.length === 0) return;
|
|
157
|
-
await commitMutation(
|
|
158
|
-
selection,
|
|
159
|
-
{
|
|
160
|
-
type: "materialize-keyframes",
|
|
161
|
-
animationId: anim.id,
|
|
162
|
-
keyframes: runtime.keyframes,
|
|
163
|
-
easeEach: runtime.easeEach,
|
|
164
|
-
},
|
|
165
|
-
{ label: "Materialize dynamic keyframes", skipReload: true },
|
|
166
|
-
);
|
|
133
|
+
// Geometry commits must never rewrite runtime/computed source implicitly.
|
|
134
|
+
// The explicit Unroll action owns that source-destructive transition.
|
|
135
|
+
void iframe;
|
|
136
|
+
void commitMutation;
|
|
137
|
+
void selection;
|
|
138
|
+
throw new GsapEditBlockedError("source-uneditable");
|
|
167
139
|
}
|
|
168
140
|
|
|
169
141
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
@@ -219,7 +191,7 @@ export async function commitStaticGsapPosition(
|
|
|
219
191
|
// The patch reuses the WRITTEN target so the runtime moves exactly the element
|
|
220
192
|
// the source write names.
|
|
221
193
|
const target = newTweenTarget(selection);
|
|
222
|
-
if (!target)
|
|
194
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
223
195
|
await callbacks.commitMutation(
|
|
224
196
|
selection,
|
|
225
197
|
{
|
|
@@ -277,7 +249,7 @@ export async function commitStaticGsapRotation(
|
|
|
277
249
|
}
|
|
278
250
|
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
|
|
279
251
|
const target = newTweenTarget(selection);
|
|
280
|
-
if (!target)
|
|
252
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
281
253
|
await callbacks.commitMutation(
|
|
282
254
|
selection,
|
|
283
255
|
{
|
|
@@ -331,7 +303,7 @@ export async function commitStaticGsapSize(
|
|
|
331
303
|
return;
|
|
332
304
|
}
|
|
333
305
|
const target = newTweenTarget(selection);
|
|
334
|
-
if (!target)
|
|
306
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
335
307
|
await callbacks.commitMutation(
|
|
336
308
|
selection,
|
|
337
309
|
{
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
|
|
3
|
+
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
|
|
4
|
+
|
|
5
|
+
export type GsapEditOutcome =
|
|
6
|
+
| { status: "persisted" }
|
|
7
|
+
| { status: "blocked"; reason: GsapEditBlockReason };
|
|
8
|
+
|
|
9
|
+
const COPY: Record<GsapEditBlockReason, string> = {
|
|
10
|
+
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|
|
11
|
+
"unroll-required":
|
|
12
|
+
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
13
|
+
"source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class GsapEditBlockedError extends Error {
|
|
17
|
+
constructor(readonly reason: GsapEditBlockReason) {
|
|
18
|
+
super(COPY[reason]);
|
|
19
|
+
this.name = "GsapEditBlockedError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
|
|
24
|
+
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
|
|
28
|
+
const editability = editabilityForProvenance(animation.provenance);
|
|
29
|
+
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
|
|
30
|
+
if (
|
|
31
|
+
editability === "source" ||
|
|
32
|
+
animation.hasUnresolvedKeyframes ||
|
|
33
|
+
animation.hasUnresolvedSelector
|
|
34
|
+
) {
|
|
35
|
+
throw new GsapEditBlockedError("source-uneditable");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isGsapEditBlockedError(error: unknown): error is GsapEditBlockedError {
|
|
40
|
+
return error instanceof GsapEditBlockedError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function animationWritesAnyProperty(
|
|
44
|
+
animation: GsapAnimation,
|
|
45
|
+
properties: ReadonlySet<string>,
|
|
46
|
+
): boolean {
|
|
47
|
+
return (
|
|
48
|
+
Object.keys(animation.properties ?? {}).some((property) => properties.has(property)) ||
|
|
49
|
+
Object.keys(animation.fromProperties ?? {}).some((property) => properties.has(property)) ||
|
|
50
|
+
!!animation.keyframes?.keyframes.some((keyframe) =>
|
|
51
|
+
Object.keys(keyframe.properties).some((property) => properties.has(property)),
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fail-closed ownership check shared by drag, resize, rotate, and inspector edits. */
|
|
57
|
+
export function directEditOutcomeForProperties(
|
|
58
|
+
animations: GsapAnimation[],
|
|
59
|
+
properties: ReadonlySet<string>,
|
|
60
|
+
): GsapEditOutcome {
|
|
61
|
+
try {
|
|
62
|
+
for (const animation of animations) {
|
|
63
|
+
if (animationWritesAnyProperty(animation, properties)) {
|
|
64
|
+
assertGsapAnimationDirectlyEditable(animation);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { status: "persisted" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -70,6 +70,8 @@ function keyframedScaleFixture(): GsapAnimation {
|
|
|
70
70
|
} as unknown as GsapAnimation;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Resize/rotation hold tests intentionally pin the same no-conversion contract.
|
|
74
|
+
// fallow-ignore-next-line code-duplication
|
|
73
75
|
it("updates a duration-zero size hold in place instead of converting it to keyframes", async () => {
|
|
74
76
|
const el = document.createElement("div");
|
|
75
77
|
el.id = "box";
|
|
@@ -96,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
|
|
|
96
98
|
commitMutation,
|
|
97
99
|
);
|
|
98
100
|
|
|
99
|
-
expect(handled).
|
|
101
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
100
102
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
101
103
|
expect(commitMutation.mock.calls[0]![1]).toEqual({
|
|
102
104
|
type: "update-properties",
|
|
@@ -115,6 +117,92 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
|
|
|
115
117
|
);
|
|
116
118
|
});
|
|
117
119
|
|
|
120
|
+
// fallow-ignore-next-line code-duplication
|
|
121
|
+
it("requires explicit unroll for helper-authored resize before mutating", async () => {
|
|
122
|
+
const el = document.createElement("div");
|
|
123
|
+
el.id = "box";
|
|
124
|
+
document.body.append(el);
|
|
125
|
+
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
|
|
126
|
+
const helperSize = {
|
|
127
|
+
id: "#box-to-size",
|
|
128
|
+
targetSelector: "#box",
|
|
129
|
+
propertyGroup: "size",
|
|
130
|
+
method: "to",
|
|
131
|
+
properties: { width: 150, height: 150 },
|
|
132
|
+
duration: 1,
|
|
133
|
+
provenance: { kind: "helper", fn: "grow", callSite: 1 },
|
|
134
|
+
} as unknown as GsapAnimation;
|
|
135
|
+
const commitMutation = vi.fn();
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
tryGsapResizeIntercept(
|
|
139
|
+
selection,
|
|
140
|
+
{ width: 344, height: 344 },
|
|
141
|
+
[helperSize],
|
|
142
|
+
null,
|
|
143
|
+
commitMutation,
|
|
144
|
+
),
|
|
145
|
+
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
|
|
146
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// fallow-ignore-next-line code-duplication
|
|
150
|
+
it("reuses the ownership parse instead of fetching a resolved size group twice", async () => {
|
|
151
|
+
const el = document.createElement("div");
|
|
152
|
+
el.id = "box";
|
|
153
|
+
document.body.append(el);
|
|
154
|
+
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
|
|
155
|
+
const sizeHold = {
|
|
156
|
+
id: "#box-set-size",
|
|
157
|
+
targetSelector: "#box",
|
|
158
|
+
propertyGroup: "size",
|
|
159
|
+
method: "set",
|
|
160
|
+
properties: { width: 150, height: 150 },
|
|
161
|
+
} as unknown as GsapAnimation;
|
|
162
|
+
const fetchAnimations = vi.fn().mockResolvedValue([sizeHold]);
|
|
163
|
+
const commitMutation = vi.fn();
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
tryGsapResizeIntercept(
|
|
167
|
+
selection,
|
|
168
|
+
{ width: 344, height: 344 },
|
|
169
|
+
[],
|
|
170
|
+
null,
|
|
171
|
+
commitMutation,
|
|
172
|
+
fetchAnimations,
|
|
173
|
+
),
|
|
174
|
+
).resolves.toEqual({ status: "persisted" });
|
|
175
|
+
expect(fetchAnimations).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
177
|
+
selection,
|
|
178
|
+
expect.objectContaining({ type: "update-properties", animationId: sizeHold.id }),
|
|
179
|
+
expect.anything(),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
|
|
184
|
+
const el = document.createElement("div");
|
|
185
|
+
el.id = "box";
|
|
186
|
+
document.body.append(el);
|
|
187
|
+
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
|
|
188
|
+
const liveSizeTween = {
|
|
189
|
+
targets: () => [el],
|
|
190
|
+
vars: { width: 300, duration: 1 },
|
|
191
|
+
duration: () => 1,
|
|
192
|
+
startTime: () => 0,
|
|
193
|
+
};
|
|
194
|
+
const iframe = {
|
|
195
|
+
contentWindow: { __timelines: { main: { getChildren: () => [liveSizeTween] } } },
|
|
196
|
+
contentDocument: document,
|
|
197
|
+
} as unknown as HTMLIFrameElement;
|
|
198
|
+
const commitMutation = vi.fn();
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
|
|
202
|
+
).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
|
|
203
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
118
206
|
it("computes a finite zero percentage for a zero-duration tween", () => {
|
|
119
207
|
const animation = {
|
|
120
208
|
id: "#box-to-0-size",
|
|
@@ -158,7 +246,7 @@ async function runResize(
|
|
|
158
246
|
commitMutation as never,
|
|
159
247
|
async () => [keyframedScaleFixture()],
|
|
160
248
|
);
|
|
161
|
-
expect(handled).
|
|
249
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
162
250
|
return committed;
|
|
163
251
|
}
|
|
164
252
|
|
|
@@ -24,11 +24,16 @@ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
|
24
24
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
25
25
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
26
26
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
27
|
-
import { isInstantHold, selectorFromSelection } from "./gsapShared";
|
|
27
|
+
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
28
28
|
import { roundTo3 } from "../utils/rounding";
|
|
29
29
|
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
|
|
30
30
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
31
31
|
import { logResize } from "../utils/resizeDebug";
|
|
32
|
+
import {
|
|
33
|
+
animationWritesAnyProperty,
|
|
34
|
+
directEditOutcomeForProperties,
|
|
35
|
+
type GsapEditOutcome,
|
|
36
|
+
} from "./gsapEditOutcome";
|
|
32
37
|
|
|
33
38
|
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
34
39
|
|
|
@@ -54,21 +59,44 @@ export async function tryGsapResizeIntercept(
|
|
|
54
59
|
iframe: HTMLIFrameElement | null,
|
|
55
60
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
56
61
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
57
|
-
): Promise<
|
|
62
|
+
): Promise<GsapEditOutcome> {
|
|
63
|
+
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
64
|
+
const allKnownAnimations = [...animations, ...fetchedAnimations];
|
|
58
65
|
// If the element already has a scale-group tween, resize should modify scale
|
|
59
66
|
// (the user is resizing something whose visual size is driven by scale).
|
|
60
67
|
// Otherwise, use the size group (width/height).
|
|
61
|
-
const hasScaleGroup =
|
|
68
|
+
const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
|
|
62
69
|
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
|
70
|
+
const resizeProperties =
|
|
71
|
+
resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
|
|
72
|
+
const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
|
|
73
|
+
if (editability.status === "blocked") return editability;
|
|
74
|
+
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
75
|
+
// The initial ownership fetch already supplied the complete parse. Only retain
|
|
76
|
+
// the fetch callback when a legacy mixed tween may be split and must then be
|
|
77
|
+
// re-read; otherwise resolveGroupTween would perform the same network read twice.
|
|
78
|
+
const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
|
|
79
|
+
? fetchFallbackAnimations
|
|
80
|
+
: undefined;
|
|
63
81
|
const resolved = await resolveGroupTween(
|
|
64
82
|
resizeGroup,
|
|
65
|
-
|
|
83
|
+
workingAnimations,
|
|
66
84
|
selection,
|
|
67
85
|
commitMutation,
|
|
68
|
-
|
|
86
|
+
postSplitFetch,
|
|
69
87
|
);
|
|
70
88
|
|
|
71
|
-
let anim =
|
|
89
|
+
let anim =
|
|
90
|
+
resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
|
|
91
|
+
? resolved.anim
|
|
92
|
+
: null;
|
|
93
|
+
const liveSelector = selectorFromSelection(selection);
|
|
94
|
+
const hasLiveResizeTween = liveSelector
|
|
95
|
+
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
|
|
96
|
+
: false;
|
|
97
|
+
if (!anim && hasLiveResizeTween) {
|
|
98
|
+
return { status: "blocked", reason: "source-uneditable" };
|
|
99
|
+
}
|
|
72
100
|
logResize("intercept-enter", {
|
|
73
101
|
hasScaleGroup,
|
|
74
102
|
resizeGroup,
|
|
@@ -77,16 +105,16 @@ export async function tryGsapResizeIntercept(
|
|
|
77
105
|
size,
|
|
78
106
|
});
|
|
79
107
|
if (!anim || isInstantHold(anim)) {
|
|
80
|
-
const sel = selectorFromSelection(selection);
|
|
81
|
-
if (!sel) return
|
|
82
|
-
const sizeSet = anim ?? findSizeSetAnimation(
|
|
108
|
+
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
109
|
+
if (!sel) return { status: "blocked", reason: "no-selector" };
|
|
110
|
+
const sizeSet = anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element);
|
|
83
111
|
|
|
84
112
|
// If the element is animated (has a real tween, not just a static size
|
|
85
113
|
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
86
114
|
// instead of a global set that resizes every frame.
|
|
87
115
|
if (resizeGroup === "size") {
|
|
88
116
|
const animatedTween = pickClosestToPlayhead(
|
|
89
|
-
|
|
117
|
+
workingAnimations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
|
|
90
118
|
);
|
|
91
119
|
if (animatedTween) {
|
|
92
120
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
@@ -98,7 +126,7 @@ export async function tryGsapResizeIntercept(
|
|
|
98
126
|
animatedTween,
|
|
99
127
|
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
100
128
|
);
|
|
101
|
-
if (handled) return
|
|
129
|
+
if (handled) return { status: "persisted" };
|
|
102
130
|
}
|
|
103
131
|
}
|
|
104
132
|
|
|
@@ -107,11 +135,11 @@ export async function tryGsapResizeIntercept(
|
|
|
107
135
|
commitMutation,
|
|
108
136
|
fetchAnimations: fetchFallbackAnimations,
|
|
109
137
|
});
|
|
110
|
-
return
|
|
138
|
+
return { status: "persisted" };
|
|
111
139
|
}
|
|
112
140
|
|
|
113
141
|
const tweenDuration = resolveTweenDuration(anim);
|
|
114
|
-
if (tweenDuration <= 0) return
|
|
142
|
+
if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
|
|
115
143
|
|
|
116
144
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
117
145
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
@@ -273,7 +301,7 @@ export async function tryGsapResizeIntercept(
|
|
|
273
301
|
"Resize animation",
|
|
274
302
|
);
|
|
275
303
|
await finalizeScaleResizeCommit();
|
|
276
|
-
return
|
|
304
|
+
return { status: "persisted" };
|
|
277
305
|
}
|
|
278
306
|
|
|
279
307
|
const ct = usePlayerStore.getState().currentTime;
|
|
@@ -385,7 +413,7 @@ export async function tryGsapResizeIntercept(
|
|
|
385
413
|
},
|
|
386
414
|
);
|
|
387
415
|
await finalizeScaleResizeCommit();
|
|
388
|
-
return
|
|
416
|
+
return { status: "persisted" };
|
|
389
417
|
}
|
|
390
418
|
|
|
391
419
|
const SIZE_PROPS = new Set(["width", "height"]);
|
|
@@ -407,7 +435,7 @@ export async function tryGsapResizeIntercept(
|
|
|
407
435
|
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
|
408
436
|
);
|
|
409
437
|
await finalizeScaleResizeCommit();
|
|
410
|
-
return
|
|
438
|
+
return { status: "persisted" };
|
|
411
439
|
}
|
|
412
440
|
|
|
413
441
|
// ── Rotation intercept ────────────────────────────────────────────────────
|