@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
|
@@ -41,19 +41,45 @@ type Commit = (
|
|
|
41
41
|
/** Renders the hook and hands its commit function to the caller via a ref callback. */
|
|
42
42
|
function renderHookWith(
|
|
43
43
|
animations: GsapAnimation[],
|
|
44
|
-
onMutation: (mutation: Record<string, unknown>, label: string) =>
|
|
44
|
+
onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
|
|
45
45
|
onReady: (commit: Commit) => void,
|
|
46
|
+
bumpGsapCache = vi.fn(),
|
|
47
|
+
onBatch?: (
|
|
48
|
+
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>,
|
|
49
|
+
label: string,
|
|
50
|
+
) => unknown | Promise<unknown>,
|
|
46
51
|
) {
|
|
47
52
|
function Harness() {
|
|
53
|
+
const gsapCommitMutation = Object.assign(
|
|
54
|
+
async (
|
|
55
|
+
_sel: DomEditSelection,
|
|
56
|
+
mutation: Record<string, unknown>,
|
|
57
|
+
options: { label: string },
|
|
58
|
+
) => {
|
|
59
|
+
await onMutation(mutation, options.label);
|
|
60
|
+
},
|
|
61
|
+
onBatch
|
|
62
|
+
? {
|
|
63
|
+
batch: async (
|
|
64
|
+
calls: Array<{
|
|
65
|
+
selection: DomEditSelection;
|
|
66
|
+
mutation: Record<string, unknown>;
|
|
67
|
+
options: { label: string };
|
|
68
|
+
}>,
|
|
69
|
+
options: { label: string },
|
|
70
|
+
) => {
|
|
71
|
+
await onBatch(calls, options.label);
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
: {},
|
|
75
|
+
);
|
|
48
76
|
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
|
|
49
77
|
selectedGsapAnimations: animations,
|
|
50
|
-
gsapCommitMutation
|
|
51
|
-
onMutation(mutation, options.label);
|
|
52
|
-
},
|
|
78
|
+
gsapCommitMutation,
|
|
53
79
|
addGsapAnimation: vi.fn(),
|
|
54
80
|
convertToKeyframes: vi.fn(),
|
|
55
81
|
previewIframeRef: { current: null },
|
|
56
|
-
bumpGsapCache
|
|
82
|
+
bumpGsapCache,
|
|
57
83
|
});
|
|
58
84
|
onReady(commitAnimatedProperties);
|
|
59
85
|
return null;
|
|
@@ -61,11 +87,106 @@ function renderHookWith(
|
|
|
61
87
|
return mountReactHarness(<Harness />);
|
|
62
88
|
}
|
|
63
89
|
|
|
90
|
+
describe("useAnimatedPropertyCommit — ownership and rejection propagation", () => {
|
|
91
|
+
it("rejects a helper-authored property before sending a mutation", async () => {
|
|
92
|
+
const helperRotation = {
|
|
93
|
+
id: "#box-to-rotation",
|
|
94
|
+
targetSelector: "#box",
|
|
95
|
+
propertyGroup: "rotation",
|
|
96
|
+
method: "to",
|
|
97
|
+
properties: { rotationX: 10 },
|
|
98
|
+
provenance: { kind: "helper", fn: "spin", callSite: 1 },
|
|
99
|
+
} as unknown as GsapAnimation;
|
|
100
|
+
const mutations: Array<Record<string, unknown>> = [];
|
|
101
|
+
let commit!: Commit;
|
|
102
|
+
const root = renderHookWith(
|
|
103
|
+
[helperRotation],
|
|
104
|
+
(mutation) => mutations.push(mutation),
|
|
105
|
+
(ready) => (commit = ready),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await expect(commit(selection, { rotationX: 12 })).rejects.toMatchObject({
|
|
109
|
+
name: "GsapEditBlockedError",
|
|
110
|
+
reason: "unroll-required",
|
|
111
|
+
});
|
|
112
|
+
expect(mutations).toHaveLength(0);
|
|
113
|
+
act(() => root.unmount());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects runtime-computed property ownership before sending a mutation", async () => {
|
|
117
|
+
const runtimePosition = {
|
|
118
|
+
...keyframedAnim,
|
|
119
|
+
hasUnresolvedKeyframes: true,
|
|
120
|
+
} as GsapAnimation;
|
|
121
|
+
const mutations: Array<Record<string, unknown>> = [];
|
|
122
|
+
let commit!: Commit;
|
|
123
|
+
const root = renderHookWith(
|
|
124
|
+
[runtimePosition],
|
|
125
|
+
(mutation) => mutations.push(mutation),
|
|
126
|
+
(ready) => (commit = ready),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await expect(commit(selection, { x: 50 })).rejects.toMatchObject({
|
|
130
|
+
reason: "source-uneditable",
|
|
131
|
+
});
|
|
132
|
+
expect(mutations).toHaveLength(0);
|
|
133
|
+
act(() => root.unmount());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects every property before a mixed-group commit can partially persist", async () => {
|
|
137
|
+
const helperOpacity = {
|
|
138
|
+
id: "#box-to-visual",
|
|
139
|
+
targetSelector: "#box",
|
|
140
|
+
propertyGroup: "visual",
|
|
141
|
+
method: "to",
|
|
142
|
+
properties: { opacity: 0.5 },
|
|
143
|
+
provenance: { kind: "helper", fn: "fade", callSite: 1 },
|
|
144
|
+
} as unknown as GsapAnimation;
|
|
145
|
+
const mutations: Array<Record<string, unknown>> = [];
|
|
146
|
+
let commit!: Commit;
|
|
147
|
+
const root = renderHookWith(
|
|
148
|
+
[helperOpacity],
|
|
149
|
+
(mutation) => mutations.push(mutation),
|
|
150
|
+
(ready) => (commit = ready),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({
|
|
154
|
+
reason: "unroll-required",
|
|
155
|
+
});
|
|
156
|
+
expect(mutations).toHaveLength(0);
|
|
157
|
+
act(() => root.unmount());
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rethrows a persistence failure to the telemetry wrapper", async () => {
|
|
161
|
+
const failure = new Error("save failed");
|
|
162
|
+
const bumpGsapCache = vi.fn();
|
|
163
|
+
let commit!: Commit;
|
|
164
|
+
const root = renderHookWith(
|
|
165
|
+
[keyframedAnim],
|
|
166
|
+
async () => {
|
|
167
|
+
throw failure;
|
|
168
|
+
},
|
|
169
|
+
(ready) => (commit = ready),
|
|
170
|
+
bumpGsapCache,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
|
|
174
|
+
expect(bumpGsapCache).toHaveBeenCalledTimes(1);
|
|
175
|
+
act(() => root.unmount());
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
64
179
|
function renderCommitHook(
|
|
65
180
|
mutations: Array<Record<string, unknown>>,
|
|
66
181
|
onReady: (commit: Commit) => void,
|
|
67
182
|
) {
|
|
68
|
-
return renderHookWith(
|
|
183
|
+
return renderHookWith(
|
|
184
|
+
[keyframedAnim],
|
|
185
|
+
(mutation) => {
|
|
186
|
+
mutations.push(mutation);
|
|
187
|
+
},
|
|
188
|
+
onReady,
|
|
189
|
+
);
|
|
69
190
|
}
|
|
70
191
|
|
|
71
192
|
// Regression (#1808): a "3D transform" / design-panel property edit on an
|
|
@@ -74,35 +195,28 @@ function renderCommitHook(
|
|
|
74
195
|
// off, it must shift the whole tween instead of adding/updating a keyframe
|
|
75
196
|
// at the playhead.
|
|
76
197
|
describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
|
|
77
|
-
|
|
78
|
-
usePlayerStore.setState({ autoKeyframeEnabled:
|
|
198
|
+
async function runCommitWithAutoKeyframe(enabled: boolean) {
|
|
199
|
+
usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
|
|
79
200
|
const mutations: Array<Record<string, unknown>> = [];
|
|
80
201
|
let commit: Commit | undefined;
|
|
81
202
|
const root = renderCommitHook(mutations, (fn) => (commit = fn));
|
|
203
|
+
await act(async () => commit!(selection, { x: 50 }));
|
|
204
|
+
act(() => root.unmount());
|
|
205
|
+
return mutations;
|
|
206
|
+
}
|
|
82
207
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
86
|
-
|
|
208
|
+
it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
|
|
209
|
+
const mutations = await runCommitWithAutoKeyframe(false);
|
|
87
210
|
expect(mutations).toHaveLength(1);
|
|
88
211
|
expect(mutations[0]!.type).toBe("replace-with-keyframes");
|
|
89
|
-
act(() => root.unmount());
|
|
90
212
|
});
|
|
91
213
|
|
|
92
214
|
it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
|
|
93
|
-
const mutations
|
|
94
|
-
let commit: Commit | undefined;
|
|
95
|
-
const root = renderCommitHook(mutations, (fn) => (commit = fn));
|
|
96
|
-
|
|
97
|
-
await act(async () => {
|
|
98
|
-
await commit!(selection, { x: 50 });
|
|
99
|
-
});
|
|
100
|
-
|
|
215
|
+
const mutations = await runCommitWithAutoKeyframe(true);
|
|
101
216
|
expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
|
|
102
217
|
true,
|
|
103
218
|
);
|
|
104
219
|
expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
|
|
105
|
-
act(() => root.unmount());
|
|
106
220
|
});
|
|
107
221
|
});
|
|
108
222
|
|
|
@@ -125,7 +239,9 @@ describe("commitStaticSet group routing", () => {
|
|
|
125
239
|
) {
|
|
126
240
|
return renderHookWith(
|
|
127
241
|
[positionSet],
|
|
128
|
-
(mutation, label) =>
|
|
242
|
+
(mutation, label) => {
|
|
243
|
+
committed.push({ mutation, label });
|
|
244
|
+
},
|
|
129
245
|
onReady,
|
|
130
246
|
);
|
|
131
247
|
}
|
|
@@ -173,7 +289,9 @@ describe("commitStaticSet group routing", () => {
|
|
|
173
289
|
let commit!: Commit;
|
|
174
290
|
renderHookWith(
|
|
175
291
|
[positionSet, instantSizeHold],
|
|
176
|
-
(mutation, label) =>
|
|
292
|
+
(mutation, label) => {
|
|
293
|
+
committed.push({ mutation, label });
|
|
294
|
+
},
|
|
177
295
|
(c) => (commit = c),
|
|
178
296
|
);
|
|
179
297
|
|
|
@@ -191,4 +309,56 @@ describe("commitStaticSet group routing", () => {
|
|
|
191
309
|
expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
|
|
192
310
|
expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
|
|
193
311
|
});
|
|
312
|
+
|
|
313
|
+
it("persists multiple property groups in one atomic batch", async () => {
|
|
314
|
+
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
|
|
315
|
+
const batches: Array<{
|
|
316
|
+
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>;
|
|
317
|
+
label: string;
|
|
318
|
+
}> = [];
|
|
319
|
+
let commit!: Commit;
|
|
320
|
+
const root = renderHookWith(
|
|
321
|
+
[positionSet],
|
|
322
|
+
(mutation, label) => committed.push({ mutation, label }),
|
|
323
|
+
(ready) => (commit = ready),
|
|
324
|
+
vi.fn(),
|
|
325
|
+
(calls, label) => batches.push({ calls, label }),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
await act(async () => {
|
|
329
|
+
await commit(selection, { x: 400, width: 500 });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(committed).toHaveLength(0);
|
|
333
|
+
expect(batches).toHaveLength(1);
|
|
334
|
+
expect(batches[0]!.label).toBe("Set properties");
|
|
335
|
+
expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([
|
|
336
|
+
{
|
|
337
|
+
type: "update-properties",
|
|
338
|
+
animationId: positionSet.id,
|
|
339
|
+
properties: { x: 400 },
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
type: "add",
|
|
343
|
+
targetSelector: "#box",
|
|
344
|
+
method: "set",
|
|
345
|
+
position: 0,
|
|
346
|
+
properties: { width: 500 },
|
|
347
|
+
global: true,
|
|
348
|
+
},
|
|
349
|
+
]);
|
|
350
|
+
act(() => root.unmount());
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("fails before sending anything when an atomic multi-group batch is unavailable", async () => {
|
|
354
|
+
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
|
|
355
|
+
let commit!: Commit;
|
|
356
|
+
const root = renderStaticHook(committed, (ready) => (commit = ready));
|
|
357
|
+
|
|
358
|
+
await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow(
|
|
359
|
+
"Atomic GSAP property batch is unavailable",
|
|
360
|
+
);
|
|
361
|
+
expect(committed).toHaveLength(0);
|
|
362
|
+
act(() => root.unmount());
|
|
363
|
+
});
|
|
194
364
|
});
|
|
@@ -24,21 +24,16 @@ import {
|
|
|
24
24
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
25
25
|
import { roundTo3 } from "../utils/rounding";
|
|
26
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
27
|
+
import {
|
|
28
|
+
assertGsapEditPersisted,
|
|
29
|
+
directEditOutcomeForProperties,
|
|
30
|
+
GsapEditBlockedError,
|
|
31
|
+
} from "./gsapEditOutcome";
|
|
32
|
+
import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
|
|
27
33
|
|
|
28
34
|
interface CommitAnimatedPropertyDeps {
|
|
29
35
|
selectedGsapAnimations: GsapAnimation[];
|
|
30
|
-
gsapCommitMutation:
|
|
31
|
-
| ((
|
|
32
|
-
selection: DomEditSelection,
|
|
33
|
-
mutation: Record<string, unknown>,
|
|
34
|
-
options: {
|
|
35
|
-
label: string;
|
|
36
|
-
coalesceKey?: string;
|
|
37
|
-
softReload?: boolean;
|
|
38
|
-
skipReload?: boolean;
|
|
39
|
-
},
|
|
40
|
-
) => Promise<void>)
|
|
41
|
-
| null;
|
|
36
|
+
gsapCommitMutation: CommitMutation | null;
|
|
42
37
|
addGsapAnimation: (
|
|
43
38
|
selection: DomEditSelection,
|
|
44
39
|
method: "to" | "from" | "set" | "fromTo",
|
|
@@ -68,6 +63,8 @@ function pickBestAnimation(
|
|
|
68
63
|
if (candidates.length === 0) return undefined;
|
|
69
64
|
if (candidates.length === 1) return candidates[0];
|
|
70
65
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
66
|
+
// Intentional multi-signal ranking: group match, selector specificity, and playhead overlap.
|
|
67
|
+
// fallow-ignore-next-line complexity
|
|
71
68
|
const scored = candidates.map((a) => {
|
|
72
69
|
let score = 0;
|
|
73
70
|
if (a.keyframes) score += 10;
|
|
@@ -107,7 +104,7 @@ async function maybeAutoKeyframeSet(
|
|
|
107
104
|
);
|
|
108
105
|
}
|
|
109
106
|
|
|
110
|
-
type Commit =
|
|
107
|
+
type Commit = CommitMutation;
|
|
111
108
|
|
|
112
109
|
/** Undo-history label for a static-set commit, from the group it writes. */
|
|
113
110
|
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
|
|
@@ -137,6 +134,17 @@ async function commitSetProps(
|
|
|
137
134
|
animations: GsapAnimation[],
|
|
138
135
|
commit: Commit,
|
|
139
136
|
): Promise<void> {
|
|
137
|
+
const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
|
|
138
|
+
await commit(call.selection, call.mutation, call.options);
|
|
139
|
+
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildSetPropsCall(
|
|
143
|
+
selection: DomEditSelection,
|
|
144
|
+
setAnim: GsapAnimation,
|
|
145
|
+
propEntries: [string, number | string][],
|
|
146
|
+
selector: string | null,
|
|
147
|
+
): CommitMutationCall {
|
|
140
148
|
const properties = Object.fromEntries(propEntries);
|
|
141
149
|
const numericProps: SetPatchProps = {};
|
|
142
150
|
for (const [k, v] of propEntries) {
|
|
@@ -152,16 +160,15 @@ async function commitSetProps(
|
|
|
152
160
|
},
|
|
153
161
|
}
|
|
154
162
|
: undefined;
|
|
155
|
-
|
|
163
|
+
return {
|
|
156
164
|
selection,
|
|
157
|
-
{ type: "update-properties", animationId: setAnim.id, properties },
|
|
158
|
-
{
|
|
165
|
+
mutation: { type: "update-properties", animationId: setAnim.id, properties },
|
|
166
|
+
options: {
|
|
159
167
|
label: staticSetLabel(propEntries),
|
|
160
168
|
softReload: true,
|
|
161
169
|
...(instantPatch ? { instantPatch } : {}),
|
|
162
170
|
},
|
|
163
|
-
|
|
164
|
-
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
|
|
171
|
+
};
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
/**
|
|
@@ -177,7 +184,25 @@ async function commitStaticSet(
|
|
|
177
184
|
animations: GsapAnimation[],
|
|
178
185
|
commit: Commit,
|
|
179
186
|
): Promise<void> {
|
|
180
|
-
|
|
187
|
+
const calls = planStaticSetCalls(selection, propEntries, selector, animations);
|
|
188
|
+
const only = calls[0];
|
|
189
|
+
if (!only) return;
|
|
190
|
+
if (calls.length === 1) {
|
|
191
|
+
await commit(only.selection, only.mutation, only.options);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!commit.batch) {
|
|
195
|
+
throw new Error("Atomic GSAP property batch is unavailable");
|
|
196
|
+
}
|
|
197
|
+
await commit.batch(calls, {
|
|
198
|
+
label: staticSetLabel(propEntries),
|
|
199
|
+
softReload: true,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function groupStaticSetEntries(
|
|
204
|
+
propEntries: [string, number | string][],
|
|
205
|
+
): Map<string, [string, number | string][]> {
|
|
181
206
|
// One commit per PROPERTY GROUP, each into a static write that owns that group —
|
|
182
207
|
// never a live tween, and never a foreign-group write (a width edit used to
|
|
183
208
|
// merge into the element's position set, producing a mixed write the split
|
|
@@ -191,9 +216,22 @@ async function commitStaticSet(
|
|
|
191
216
|
batch.push(entry);
|
|
192
217
|
byGroup.set(group, batch);
|
|
193
218
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
219
|
+
return byGroup;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function planStaticSetCalls(
|
|
223
|
+
selection: DomEditSelection,
|
|
224
|
+
propEntries: [string, number | string][],
|
|
225
|
+
selector: string | null,
|
|
226
|
+
animations: GsapAnimation[],
|
|
227
|
+
): CommitMutationCall[] {
|
|
228
|
+
const byGroup = groupStaticSetEntries(propEntries);
|
|
229
|
+
const staticWrites = selector
|
|
230
|
+
? animations.filter(
|
|
231
|
+
(a) =>
|
|
232
|
+
isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
|
|
233
|
+
)
|
|
234
|
+
: [];
|
|
197
235
|
// Resolve every group's target BEFORE committing anything, and coalesce
|
|
198
236
|
// groups that land on the SAME write into one commit: the snapshot is captured
|
|
199
237
|
// once, so if two groups resolved to one legacy mixed write, a first
|
|
@@ -209,13 +247,12 @@ async function commitStaticSet(
|
|
|
209
247
|
newSetBatches.push(batch);
|
|
210
248
|
}
|
|
211
249
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
250
|
+
return [
|
|
251
|
+
...[...byTargetWrite].map(([targetWrite, batch]) =>
|
|
252
|
+
buildSetPropsCall(selection, targetWrite, batch, selector),
|
|
253
|
+
),
|
|
254
|
+
...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
|
|
255
|
+
];
|
|
219
256
|
}
|
|
220
257
|
|
|
221
258
|
/**
|
|
@@ -241,11 +278,10 @@ function findGroupOwningStaticWrite(
|
|
|
241
278
|
* the timeline (matches the manual-drag UX). The global-set instant patch applies
|
|
242
279
|
* it straight to the element so the first edit shows with no soft-reload flash.
|
|
243
280
|
*/
|
|
244
|
-
|
|
281
|
+
function buildGlobalStaticSetCall(
|
|
245
282
|
selection: DomEditSelection,
|
|
246
283
|
batch: [string, number | string][],
|
|
247
|
-
|
|
248
|
-
): Promise<void> {
|
|
284
|
+
): CommitMutationCall {
|
|
249
285
|
const numericProps: SetPatchProps = {};
|
|
250
286
|
for (const [k, v] of batch) {
|
|
251
287
|
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
|
@@ -254,10 +290,10 @@ async function addGlobalStaticSet(
|
|
|
254
290
|
// selector is the bare class an id-less element yields, which would hold every
|
|
255
291
|
// sibling. No one-element form means no write at all (see writeTargetSelector).
|
|
256
292
|
const target = writeTargetSelector(selection);
|
|
257
|
-
if (!target)
|
|
258
|
-
|
|
293
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
294
|
+
return {
|
|
259
295
|
selection,
|
|
260
|
-
{
|
|
296
|
+
mutation: {
|
|
261
297
|
type: "add",
|
|
262
298
|
targetSelector: target,
|
|
263
299
|
method: "set",
|
|
@@ -265,7 +301,7 @@ async function addGlobalStaticSet(
|
|
|
265
301
|
properties: Object.fromEntries(batch),
|
|
266
302
|
global: true,
|
|
267
303
|
},
|
|
268
|
-
{
|
|
304
|
+
options: {
|
|
269
305
|
label: staticSetLabel(batch),
|
|
270
306
|
softReload: true,
|
|
271
307
|
...(Object.keys(numericProps).length > 0
|
|
@@ -277,7 +313,7 @@ async function addGlobalStaticSet(
|
|
|
277
313
|
}
|
|
278
314
|
: {}),
|
|
279
315
|
},
|
|
280
|
-
|
|
316
|
+
};
|
|
281
317
|
}
|
|
282
318
|
|
|
283
319
|
/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
|
|
@@ -393,11 +429,19 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
393
429
|
const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;
|
|
394
430
|
|
|
395
431
|
const commitAnimatedProperties = useCallback(
|
|
432
|
+
// This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
|
|
433
|
+
// fallow-ignore-next-line complexity
|
|
396
434
|
async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
|
|
397
435
|
if (!gsapCommitMutation) return;
|
|
398
436
|
const propEntries = Object.entries(props);
|
|
399
437
|
if (propEntries.length === 0) return;
|
|
400
438
|
const primaryProp = propEntries[0]![0];
|
|
439
|
+
assertGsapEditPersisted(
|
|
440
|
+
directEditOutcomeForProperties(
|
|
441
|
+
selectedGsapAnimations,
|
|
442
|
+
new Set(propEntries.map(([property]) => property)),
|
|
443
|
+
),
|
|
444
|
+
);
|
|
401
445
|
|
|
402
446
|
const iframe = previewIframeRef.current;
|
|
403
447
|
const selector = selectorFromSelection(selection);
|
|
@@ -407,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
407
451
|
selector,
|
|
408
452
|
primaryProp,
|
|
409
453
|
);
|
|
454
|
+
if (!anim && !writeTargetSelector(selection)) {
|
|
455
|
+
throw new GsapEditBlockedError("no-selector");
|
|
456
|
+
}
|
|
410
457
|
// Whether the element is animated at all. A 3D edit only creates/edits
|
|
411
458
|
// keyframes when it IS — a static element (no keyframes on any of its tweens)
|
|
412
459
|
// gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
|
|
@@ -461,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
461
508
|
return;
|
|
462
509
|
}
|
|
463
510
|
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
511
|
+
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
|
|
512
|
+
// keyframes (incl. the no-animation case, which creates a fresh set).
|
|
513
|
+
// Route the complete property set through the group-aware planner even
|
|
514
|
+
// when pickBestAnimation found one existing set: a mixed X+width edit
|
|
515
|
+
// must update the position set AND create a size set atomically rather
|
|
516
|
+
// than contaminating the first set with a foreign property group.
|
|
517
|
+
if (!elementHasKeyframes) {
|
|
518
|
+
await commitStaticSet(
|
|
468
519
|
selection,
|
|
469
|
-
anim,
|
|
470
520
|
propEntries,
|
|
471
521
|
selector,
|
|
472
522
|
selectedGsapAnimations,
|
|
@@ -475,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
475
525
|
return;
|
|
476
526
|
}
|
|
477
527
|
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
if (
|
|
481
|
-
await
|
|
528
|
+
// Existing static hold on an otherwise animated element — merge the props
|
|
529
|
+
// into the same write, then auto-keyframe it against the sibling tween.
|
|
530
|
+
if (anim && isInstantHold(anim)) {
|
|
531
|
+
await commitSetProps(
|
|
482
532
|
selection,
|
|
533
|
+
anim,
|
|
483
534
|
propEntries,
|
|
484
535
|
selector,
|
|
485
536
|
selectedGsapAnimations,
|
|
@@ -498,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
498
549
|
// one-element form the edit is dropped rather than written onto every
|
|
499
550
|
// class sibling (see writeTargetSelector).
|
|
500
551
|
const newTweenTarget = writeTargetSelector(selection);
|
|
501
|
-
if (
|
|
552
|
+
if (newTweenTarget) {
|
|
502
553
|
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
|
|
503
554
|
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
|
|
504
555
|
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
|
|
@@ -528,9 +579,10 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
528
579
|
);
|
|
529
580
|
return;
|
|
530
581
|
}
|
|
582
|
+
throw new GsapEditBlockedError("no-selector");
|
|
583
|
+
} catch (error) {
|
|
531
584
|
bumpGsapCache();
|
|
532
|
-
|
|
533
|
-
bumpGsapCache();
|
|
585
|
+
throw error;
|
|
534
586
|
}
|
|
535
587
|
},
|
|
536
588
|
[selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],
|