@hyperframes/studio 0.8.115 → 0.8.117
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-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
- package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
- package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
- package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
- package/dist/index.d.ts +132 -41
- package/dist/index.html +1 -1
- package/dist/index.js +1184 -1084
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +8 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +2 -105
- package/src/hooks/gsapDragPositionCommit.ts +30 -393
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +63 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -169
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +9 -1
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +7 -9
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +32 -1
- package/src/hooks/useEnableKeyframes.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +7 -3
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +17 -8
- package/src/hooks/useGsapAwareGroupMove.ts +18 -18
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
- package/src/hooks/useGsapScriptCommits.ts +21 -17
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +302 -3
- package/src/hooks/usePersistentEditHistory.ts +50 -17
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.ts +2 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/sceneSwap.ts +2 -1
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +39 -0
- package/src/utils/gsapSoftReload.ts +9 -14
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +5 -6
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +5 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.ts +2 -1
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +2 -1
- package/dist/chunk-TXZMMOMA.js.map +0 -1
- /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* easing, or seek position.
|
|
10
10
|
*/
|
|
11
11
|
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
12
|
-
import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
|
|
12
|
+
import { isXYPositionWrite, PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
|
|
13
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
14
14
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
15
15
|
|
|
16
16
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
17
|
-
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
17
|
+
import { commitGsapPositionFromDrag, gsapPositionFromDragOutcome } from "./gsapDragPositionCommit";
|
|
18
|
+
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
|
|
18
19
|
import {
|
|
19
20
|
commitStaticGsapPosition,
|
|
20
21
|
commitStaticGsapRotation,
|
|
@@ -22,7 +23,6 @@ import {
|
|
|
22
23
|
computeCurrentPercentage,
|
|
23
24
|
findExistingPositionWrite,
|
|
24
25
|
findRotationSetAnimation,
|
|
25
|
-
materializeIfDynamic,
|
|
26
26
|
} from "./gsapDragCommit";
|
|
27
27
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
28
28
|
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
@@ -54,64 +54,29 @@ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
|
|
|
54
54
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* Resolution order:
|
|
61
|
-
* 1. Tween already tagged with `propertyGroup === group`
|
|
62
|
-
* 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
|
|
63
|
-
* re-fetch, then return the group tween
|
|
64
|
-
* 3. null — caller must handle the missing-tween case
|
|
57
|
+
* The tween to edit for a property group: a tween tagged with it, else a legacy tween that mixes
|
|
58
|
+
* it with other groups. A mixed tween is edited in place, never split first: inside a gesture the
|
|
59
|
+
* split is only buffered, so ids read after it are stale and the edit lands beside the old tween.
|
|
65
60
|
*/
|
|
66
61
|
export async function resolveGroupTween(
|
|
67
62
|
group: PropertyGroupName,
|
|
68
63
|
animations: GsapAnimation[],
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
_selection: DomEditSelection,
|
|
65
|
+
_commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
71
66
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
72
67
|
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ label: "Split mixed tween into property groups", skipReload: true },
|
|
86
|
-
);
|
|
87
|
-
if (fetchFallbackAnimations) {
|
|
88
|
-
const fresh = await fetchFallbackAnimations();
|
|
89
|
-
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
|
|
90
|
-
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 3. Try fallback fetch (no split needed, just wasn't in the initial list)
|
|
95
|
-
if (!legacyMixed && fetchFallbackAnimations) {
|
|
96
|
-
const fresh = await fetchFallbackAnimations();
|
|
97
|
-
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
|
|
98
|
-
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
|
|
99
|
-
|
|
100
|
-
// Fallback: legacy mixed in the fresh list
|
|
101
|
-
const freshLegacy = fresh.find((a) => !a.propertyGroup);
|
|
102
|
-
if (freshLegacy) {
|
|
103
|
-
await commitMutation(
|
|
104
|
-
selection,
|
|
105
|
-
{ type: "split-into-property-groups", animationId: freshLegacy.id },
|
|
106
|
-
{ label: "Split mixed tween into property groups", skipReload: true },
|
|
107
|
-
);
|
|
108
|
-
const reFetched = await fetchFallbackAnimations();
|
|
109
|
-
const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
|
|
110
|
-
if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return null;
|
|
68
|
+
const inGroup = (list: GsapAnimation[]) => {
|
|
69
|
+
const tagged = list.filter((a) => a.propertyGroup === group);
|
|
70
|
+
const props = new Set(PROPERTY_GROUPS[group]);
|
|
71
|
+
const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
|
|
72
|
+
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
|
|
73
|
+
};
|
|
74
|
+
const anim = inGroup(animations);
|
|
75
|
+
if (anim) return { anim, animations };
|
|
76
|
+
if (!fetchFallbackAnimations) return null;
|
|
77
|
+
const fresh = await fetchFallbackAnimations();
|
|
78
|
+
const freshAnim = inGroup(fresh);
|
|
79
|
+
return freshAnim ? { anim: freshAnim, animations: fresh } : null;
|
|
115
80
|
}
|
|
116
81
|
|
|
117
82
|
// ── High-level intercept ───────────────────────────────────────────────────
|
|
@@ -205,6 +170,59 @@ function oneUndoStep(
|
|
|
205
170
|
commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
|
|
206
171
|
}
|
|
207
172
|
|
|
173
|
+
const isPositionWriteOf = (selector: string) => (a: GsapAnimation) =>
|
|
174
|
+
a.targetSelector === selector && isXYPositionWrite(a);
|
|
175
|
+
|
|
176
|
+
/** The one position write a self-heal keeps when holds fight over `selector`, else null. */
|
|
177
|
+
function positionWriteKeeper(animations: GsapAnimation[], selector: string): GsapAnimation | null {
|
|
178
|
+
const dupes = animations.filter(isPositionWriteOf(selector));
|
|
179
|
+
// Real tweens one after another are a motion, not a conflict: only holds can fight.
|
|
180
|
+
if (dupes.length < 2 || dupes.filter((a) => !isInstantHold(a)).length > 1) return null;
|
|
181
|
+
return dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Where a drag writes. With no live motion and no keyframed tween (a hold, or a
|
|
185
|
+
* zero-length keyframed tween) the position belongs in a `tl.set`, never keyframes. */
|
|
186
|
+
function dragRoute(
|
|
187
|
+
posAnim: GsapAnimation | null,
|
|
188
|
+
iframe: HTMLIFrameElement | null,
|
|
189
|
+
selector: string,
|
|
190
|
+
altKey?: boolean,
|
|
191
|
+
): "static" | "whole-path" | "at-playhead" {
|
|
192
|
+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
193
|
+
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
194
|
+
if (!hasNonHold && !hasKeyframedPosTween) return "static";
|
|
195
|
+
// Alt-drag shifts the whole path; with auto-keyframe off (#1808) that is the default.
|
|
196
|
+
return altKey || !usePlayerStore.getState().autoKeyframeEnabled ? "whole-path" : "at-playhead";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The commit's route and keyframe plan, writing nothing, so a group refuses whole. */
|
|
200
|
+
async function planDrag(
|
|
201
|
+
selection: DomEditSelection,
|
|
202
|
+
offset: { x: number; y: number },
|
|
203
|
+
allAnimations: GsapAnimation[],
|
|
204
|
+
iframe: HTMLIFrameElement | null,
|
|
205
|
+
options: { altKey?: boolean },
|
|
206
|
+
): Promise<GsapEditOutcome> {
|
|
207
|
+
const selector = selectorFromSelection(selection);
|
|
208
|
+
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
209
|
+
const own = tweensForThisElement(selection, allAnimations);
|
|
210
|
+
const keeper = positionWriteKeeper(own, selector);
|
|
211
|
+
const animations = keeper
|
|
212
|
+
? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
|
|
213
|
+
: own;
|
|
214
|
+
const resolved = await resolveGroupTween("position", animations, selection, async () => {});
|
|
215
|
+
const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector);
|
|
216
|
+
const route = dragRoute(posAnim, iframe, selector, options.altKey);
|
|
217
|
+
if (route === "static") return { status: "persisted" };
|
|
218
|
+
if (!posAnim) {
|
|
219
|
+
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
|
|
220
|
+
}
|
|
221
|
+
if (route === "whole-path") return { status: "persisted" };
|
|
222
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
223
|
+
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
/** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
|
|
209
227
|
* restores its drafts) and save `element-offset` on the element itself. */
|
|
210
228
|
export async function tryGsapDragIntercept(
|
|
@@ -229,7 +247,12 @@ export async function tryGsapDragIntercept(
|
|
|
229
247
|
fetchAllAnimations,
|
|
230
248
|
options?.group,
|
|
231
249
|
);
|
|
232
|
-
if (preflight.status !== "persisted"
|
|
250
|
+
if (preflight.status !== "persisted") return preflight;
|
|
251
|
+
if (options?.preflightOnly) {
|
|
252
|
+
return options.group
|
|
253
|
+
? planDrag(selection, offset, allAnimations, iframe, options)
|
|
254
|
+
: preflight;
|
|
255
|
+
}
|
|
233
256
|
}
|
|
234
257
|
const animations = tweensForThisElement(selection, allAnimations);
|
|
235
258
|
const fetchFallbackAnimations =
|
|
@@ -246,13 +269,10 @@ export async function tryGsapDragIntercept(
|
|
|
246
269
|
// the live keyframed/real tween if present (else any), strip the rest, so the
|
|
247
270
|
// commit below updates ONE write instead of fighting duplicates.
|
|
248
271
|
let workingAnimations = animations;
|
|
249
|
-
|
|
250
|
-
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
|
|
272
|
+
if (animations.filter(isPositionWriteOf(selector)).length > 1 && fetchFallbackAnimations) {
|
|
251
273
|
const fresh = await fetchFallbackAnimations();
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
const keeper =
|
|
255
|
-
dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
|
|
274
|
+
const keeper = positionWriteKeeper(fresh, selector);
|
|
275
|
+
if (keeper) {
|
|
256
276
|
await commitMutation(
|
|
257
277
|
selection,
|
|
258
278
|
{
|
|
@@ -288,26 +308,8 @@ export async function tryGsapDragIntercept(
|
|
|
288
308
|
}
|
|
289
309
|
|
|
290
310
|
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
|
|
294
|
-
// position-hold `set` (after a delete-all, or a stale parse that lags it) must
|
|
295
|
-
// NOT count as live motion. Either way the position belongs in a
|
|
296
|
-
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
297
|
-
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
298
|
-
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
299
|
-
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
300
|
-
// A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
|
|
301
|
-
// e.g. 0% and 100% identical) — is still an animation the user is building, so a
|
|
302
|
-
// drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
|
|
303
|
-
// dragging an element whose position tween is constant writes a `gsap.set` that
|
|
304
|
-
// fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
|
|
305
|
-
// static path is only for elements with NO keyframed position tween (truly static,
|
|
306
|
-
// or just a leftover position-hold `set`).
|
|
307
|
-
// A zero-duration keyframed tween is a static HOLD, not a live animation —
|
|
308
|
-
// treat it as static so the drag heals it instead of feeding it more keyframes.
|
|
309
|
-
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
310
|
-
if (!hasNonHold && !hasKeyframedPosTween) {
|
|
311
|
+
const route = dragRoute(posAnim, iframe, selector, options?.altKey);
|
|
312
|
+
if (route === "static") {
|
|
311
313
|
const existingSet =
|
|
312
314
|
posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
|
|
313
315
|
? posAnim
|
|
@@ -329,27 +331,26 @@ export async function tryGsapDragIntercept(
|
|
|
329
331
|
// current ID and avoid a stale-ID remove that creates duplicate tweens.
|
|
330
332
|
if (fetchFallbackAnimations) {
|
|
331
333
|
const fresh = await fetchFallbackAnimations();
|
|
332
|
-
const freshMatch =
|
|
333
|
-
(a) =>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
334
|
+
const freshMatch =
|
|
335
|
+
fresh.find((a) => a.id === posAnim!.id) ??
|
|
336
|
+
pickClosestToPlayhead(
|
|
337
|
+
fresh.filter(
|
|
338
|
+
(a) =>
|
|
339
|
+
a.targetSelector === posAnim!.targetSelector &&
|
|
340
|
+
a.propertyGroup === posAnim!.propertyGroup &&
|
|
341
|
+
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
338
344
|
if (freshMatch && freshMatch.id !== posAnim.id) {
|
|
339
345
|
posAnim = freshMatch;
|
|
340
346
|
}
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
344
|
-
|
|
345
|
-
// toggle (#1808) just makes that the default while it's off, so a manual
|
|
346
|
-
// edit on an already-animated element nudges the animation instead of
|
|
347
|
-
// inserting/updating a keyframe at the playhead.
|
|
348
|
-
const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
|
|
349
|
-
if (options?.altKey || !autoKeyframeEnabled) {
|
|
350
|
+
if (route === "whole-path") {
|
|
350
351
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
351
352
|
} else {
|
|
352
|
-
|
|
353
|
+
return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs);
|
|
353
354
|
}
|
|
354
355
|
return { status: "persisted" };
|
|
355
356
|
}
|
|
@@ -428,8 +429,9 @@ export async function tryGsapRotationIntercept(
|
|
|
428
429
|
? resolved.anim
|
|
429
430
|
: null;
|
|
430
431
|
if (!anim) {
|
|
431
|
-
anim =
|
|
432
|
-
workingAnimations.
|
|
432
|
+
anim = pickClosestToPlayhead(
|
|
433
|
+
workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
|
|
434
|
+
);
|
|
433
435
|
}
|
|
434
436
|
|
|
435
437
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
@@ -469,42 +471,14 @@ export async function tryGsapRotationIntercept(
|
|
|
469
471
|
return { status: "persisted" };
|
|
470
472
|
}
|
|
471
473
|
|
|
472
|
-
|
|
473
|
-
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
474
|
-
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
475
|
-
if (newId) anim = { ...anim, id: newId };
|
|
476
|
-
} else if (!anim.keyframes) {
|
|
477
|
-
const resolvedFromValues = selector
|
|
478
|
-
? readAllAnimatedProperties(iframe, selector, anim, "rotation")
|
|
479
|
-
: undefined;
|
|
480
|
-
await commitMutation(
|
|
481
|
-
selection,
|
|
482
|
-
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
483
|
-
{ label: "Convert to keyframes for rotation", skipReload: true },
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
|
|
488
|
-
|
|
489
|
-
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
|
490
|
-
if (!("rotation" in runtimeProps)) {
|
|
491
|
-
backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const properties = { ...runtimeProps, rotation: newRotation };
|
|
495
|
-
|
|
496
|
-
await commitMutation(
|
|
474
|
+
return commitValueAtPlayhead(
|
|
497
475
|
selection,
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
backfillDefaults,
|
|
504
|
-
},
|
|
505
|
-
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
|
|
476
|
+
anim,
|
|
477
|
+
{ rotation: newRotation },
|
|
478
|
+
iframe,
|
|
479
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
480
|
+
{ label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
|
|
506
481
|
);
|
|
507
|
-
return { status: "persisted" };
|
|
508
482
|
}
|
|
509
483
|
|
|
510
484
|
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
@@ -449,12 +449,12 @@ export const ROTATION_CHANNELS: string[] = [
|
|
|
449
449
|
|
|
450
450
|
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
|
|
451
451
|
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
|
|
452
|
-
|
|
452
|
+
function gsapWritesPosition(el: Element): boolean {
|
|
453
453
|
return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
|
|
454
454
|
}
|
|
455
455
|
|
|
456
456
|
/** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
|
|
457
|
-
|
|
457
|
+
function gsapWritesRotation(el: Element): boolean {
|
|
458
458
|
return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -474,10 +474,23 @@ const BOX_CHANNELS = [
|
|
|
474
474
|
];
|
|
475
475
|
|
|
476
476
|
/** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
|
|
477
|
-
|
|
477
|
+
function gsapWritesBox(el: Element): boolean {
|
|
478
478
|
return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
export type EditGesture = "move" | "resize" | "rotate";
|
|
482
|
+
const GSAP_OWNS: Record<EditGesture, (el: Element) => boolean> = {
|
|
483
|
+
move: gsapWritesPosition,
|
|
484
|
+
resize: gsapWritesBox,
|
|
485
|
+
rotate: gsapWritesRotation,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/** The one GSAP-or-CSS route decision. Make it when an edit starts and carry it: mid-gesture
|
|
489
|
+
* writes make GSAP render the element, so asking again at commit can flip the route. */
|
|
490
|
+
export function editsPlainCss(el: Element, gesture: EditGesture): boolean {
|
|
491
|
+
return !GSAP_OWNS[gesture](el);
|
|
492
|
+
}
|
|
493
|
+
|
|
481
494
|
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
|
|
482
495
|
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
|
|
483
496
|
const win = el.ownerDocument.defaultView as {
|
|
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
4
4
|
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
5
5
|
import { idSelector, matchesExactlyOne } from "./gsapShared";
|
|
6
6
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
7
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The selector to author a NEW tween against, minting an id on the element when
|
|
@@ -116,7 +117,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
|
|
|
116
117
|
showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
|
|
117
118
|
return false;
|
|
118
119
|
}
|
|
119
|
-
const res = await
|
|
120
|
+
const res = await studioApiFetch(
|
|
120
121
|
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
121
122
|
{
|
|
122
123
|
method: "POST",
|
|
@@ -5,6 +5,14 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
|
5
5
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
6
6
|
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
|
|
7
7
|
|
|
8
|
+
/** The file a selection's GSAP write goes to. */
|
|
9
|
+
export function gsapWriteFile(
|
|
10
|
+
selection: Pick<DomEditSelection, "sourceFile">,
|
|
11
|
+
activeCompPath: string | null | undefined,
|
|
12
|
+
): string {
|
|
13
|
+
return selection.sourceFile || activeCompPath || "index.html";
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export interface MutationResult {
|
|
9
17
|
ok: boolean;
|
|
10
18
|
changed?: boolean;
|
|
@@ -36,7 +44,7 @@ export interface CommitMutationOptions {
|
|
|
36
44
|
* error handling — a failed write still throws.
|
|
37
45
|
*/
|
|
38
46
|
deferPreviewSync?: boolean;
|
|
39
|
-
/**
|
|
47
|
+
/** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
|
|
40
48
|
previewFallbackLatch?: { pending: boolean };
|
|
41
49
|
beforeReload?: () => void;
|
|
42
50
|
/**
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
|
34
34
|
height: 100,
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
|
|
38
|
+
* since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
|
|
39
|
+
export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
|
|
40
|
+
if (!anim.keyframes) return { easeEach: anim.ease };
|
|
41
|
+
return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* A timeline write that applies an instantaneous value and then holds it.
|
|
39
46
|
* `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
|
|
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { parseGsapScript } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
-
import {
|
|
6
|
+
import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
|
|
7
7
|
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
|
|
8
8
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
9
9
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
@@ -16,38 +16,6 @@ afterEach(() => {
|
|
|
16
16
|
document.body.innerHTML = "";
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* A selection built the way production builds it (getDomLayerPatchTarget), so
|
|
21
|
-
* the class-only case under test is the real one: buildStableSelector hands back
|
|
22
|
-
* a BARE class for an element with no id / hf-id / composition id.
|
|
23
|
-
*/
|
|
24
|
-
function selectionFor(el: HTMLElement): DomEditSelection {
|
|
25
|
-
const selector = buildStableSelector(el);
|
|
26
|
-
return {
|
|
27
|
-
element: el,
|
|
28
|
-
id: el.id || undefined,
|
|
29
|
-
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
30
|
-
selector,
|
|
31
|
-
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
|
|
32
|
-
sourceFile: "index.html",
|
|
33
|
-
dataAttributes: { start: "0", duration: "2" },
|
|
34
|
-
} as unknown as DomEditSelection;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Five class-only siblings — the shape that made one add collapse the timeline. */
|
|
38
|
-
function mountGroupSiblings(): HTMLElement[] {
|
|
39
|
-
document.body.innerHTML = `
|
|
40
|
-
<div id="scene" class="clip" data-start="0" data-duration="2">
|
|
41
|
-
<div class="group"></div>
|
|
42
|
-
<div class="group"></div>
|
|
43
|
-
<div class="group"></div>
|
|
44
|
-
<div class="group"></div>
|
|
45
|
-
<div class="group"></div>
|
|
46
|
-
</div>
|
|
47
|
-
`;
|
|
48
|
-
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
19
|
const BASE_SCRIPT = `
|
|
52
20
|
const tl = gsap.timeline({ paused: true });
|
|
53
21
|
tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
|
|
@@ -68,8 +36,8 @@ function roundTripTargets(selector: string): { script: string; targets: string[]
|
|
|
68
36
|
|
|
69
37
|
describe("writeTargetSelector", () => {
|
|
70
38
|
it("addresses exactly one element when the only identity is a shared class", () => {
|
|
71
|
-
const groups = mountGroupSiblings();
|
|
72
|
-
const selection =
|
|
39
|
+
const groups = mountGroupSiblings(5);
|
|
40
|
+
const selection = stableSelectionFor(groups[2]);
|
|
73
41
|
|
|
74
42
|
// Precondition: this is the defect's input — a bare class hitting all five.
|
|
75
43
|
expect(selection.selector).toBe(".group");
|
|
@@ -86,7 +54,7 @@ describe("writeTargetSelector", () => {
|
|
|
86
54
|
document.body.innerHTML = `<div id="box" class="card"></div>`;
|
|
87
55
|
const el = document.querySelector<HTMLElement>("#box")!;
|
|
88
56
|
|
|
89
|
-
expect(writeTargetSelector(
|
|
57
|
+
expect(writeTargetSelector(stableSelectionFor(el))).toBe("#box");
|
|
90
58
|
});
|
|
91
59
|
|
|
92
60
|
it("prefers data-hf-id over a generated structural selector", () => {
|
|
@@ -95,7 +63,7 @@ describe("writeTargetSelector", () => {
|
|
|
95
63
|
`;
|
|
96
64
|
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
97
65
|
|
|
98
|
-
const written = writeTargetSelector(
|
|
66
|
+
const written = writeTargetSelector(stableSelectionFor(el));
|
|
99
67
|
|
|
100
68
|
expect(written).toBe('[data-hf-id="hf-42"]');
|
|
101
69
|
expect(document.querySelector(written!)).toBe(el);
|
|
@@ -105,7 +73,7 @@ describe("writeTargetSelector", () => {
|
|
|
105
73
|
document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
|
|
106
74
|
const el = document.querySelector<HTMLElement>(".header")!;
|
|
107
75
|
|
|
108
|
-
expect(writeTargetSelector(
|
|
76
|
+
expect(writeTargetSelector(stableSelectionFor(el))).toBe(".header");
|
|
109
77
|
});
|
|
110
78
|
|
|
111
79
|
it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
|
|
@@ -125,7 +93,7 @@ describe("writeTargetSelector", () => {
|
|
|
125
93
|
`;
|
|
126
94
|
const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
|
|
127
95
|
|
|
128
|
-
const written = writeTargetSelector(
|
|
96
|
+
const written = writeTargetSelector(stableSelectionFor(el));
|
|
129
97
|
|
|
130
98
|
expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
|
|
131
99
|
expect(document.querySelectorAll(written!)).toHaveLength(1);
|
|
@@ -137,7 +105,7 @@ describe("writeTargetSelector", () => {
|
|
|
137
105
|
const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
|
|
138
106
|
// querySelectorAll throws on this, so the "does it match exactly one?" rung
|
|
139
107
|
// must read as a miss rather than crashing the add.
|
|
140
|
-
const selection = { ...
|
|
108
|
+
const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
|
|
141
109
|
|
|
142
110
|
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
143
111
|
});
|
|
@@ -145,7 +113,7 @@ describe("writeTargetSelector", () => {
|
|
|
145
113
|
it("returns null when a live DOM is present and no rung addresses one element", () => {
|
|
146
114
|
document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
|
|
147
115
|
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
148
|
-
const selection =
|
|
116
|
+
const selection = stableSelectionFor(el);
|
|
149
117
|
expect(selection.selector).toBe(".group");
|
|
150
118
|
// Detached between selecting and committing: ownerDocument still resolves,
|
|
151
119
|
// but there is no parent to count a :nth-child position in. Returning
|
|
@@ -167,11 +135,11 @@ describe("writeTargetSelector", () => {
|
|
|
167
135
|
|
|
168
136
|
describe("writeTargetSelector — write/read round trip", () => {
|
|
169
137
|
it("re-parses and attributes the new tween to the one element it targeted", () => {
|
|
170
|
-
const groups = mountGroupSiblings();
|
|
138
|
+
const groups = mountGroupSiblings(5);
|
|
171
139
|
// Ids let resolveSelectorElementIds name the attributed elements; the
|
|
172
140
|
// SELECTION still has none, so the write path still faces the bare class.
|
|
173
141
|
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
|
|
174
|
-
const selection = { ...
|
|
142
|
+
const selection = { ...stableSelectionFor(groups[2]), id: undefined } as DomEditSelection;
|
|
175
143
|
|
|
176
144
|
const written = writeTargetSelector(selection);
|
|
177
145
|
const { targets } = roundTripTargets(written!);
|
|
@@ -180,9 +148,9 @@ describe("writeTargetSelector — write/read round trip", () => {
|
|
|
180
148
|
});
|
|
181
149
|
|
|
182
150
|
it("does not widen the add to the element's four class siblings", () => {
|
|
183
|
-
const groups = mountGroupSiblings();
|
|
151
|
+
const groups = mountGroupSiblings(5);
|
|
184
152
|
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
|
|
185
|
-
const selection = { ...
|
|
153
|
+
const selection = { ...stableSelectionFor(groups[0]), id: undefined } as DomEditSelection;
|
|
186
154
|
|
|
187
155
|
// The old bare-class write attributed the one add to every sibling — the
|
|
188
156
|
// attribution blow-up behind "one add collapsed the timeline to a single row".
|
|
@@ -194,8 +162,8 @@ describe("writeTargetSelector — write/read round trip", () => {
|
|
|
194
162
|
|
|
195
163
|
describe("existingTweenTargetSelector", () => {
|
|
196
164
|
it("keeps the narrowed target when a replace re-authors the tween", async () => {
|
|
197
|
-
const groups = mountGroupSiblings();
|
|
198
|
-
const selection =
|
|
165
|
+
const groups = mountGroupSiblings(5);
|
|
166
|
+
const selection = stableSelectionFor(groups[2]);
|
|
199
167
|
const commitMutation = vi.fn(async () => undefined);
|
|
200
168
|
// A tween a previous add already narrowed to one sibling. Re-deriving the
|
|
201
169
|
// target from the selection would widen it back to all five.
|
|
@@ -215,8 +183,8 @@ describe("existingTweenTargetSelector", () => {
|
|
|
215
183
|
});
|
|
216
184
|
|
|
217
185
|
it("falls back to the selection when the tween's own target did not resolve", async () => {
|
|
218
|
-
const groups = mountGroupSiblings();
|
|
219
|
-
const selection =
|
|
186
|
+
const groups = mountGroupSiblings(5);
|
|
187
|
+
const selection = stableSelectionFor(groups[2]);
|
|
220
188
|
const commitMutation = vi.fn(async () => undefined);
|
|
221
189
|
const setAnim = {
|
|
222
190
|
id: "a1",
|
|
@@ -236,8 +204,8 @@ describe("existingTweenTargetSelector", () => {
|
|
|
236
204
|
|
|
237
205
|
describe("commitKeyframeAtTimeImpl — new-tween target", () => {
|
|
238
206
|
it("authors a one-element selector when no tween exists at the playhead", async () => {
|
|
239
|
-
const groups = mountGroupSiblings();
|
|
240
|
-
const selection =
|
|
207
|
+
const groups = mountGroupSiblings(5);
|
|
208
|
+
const selection = stableSelectionFor(groups[3]);
|
|
241
209
|
const commitMutation = vi.fn(async () => undefined);
|
|
242
210
|
|
|
243
211
|
await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
|
|
@@ -251,7 +219,7 @@ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
|
|
|
251
219
|
|
|
252
220
|
describe("tweenTargetsElement", () => {
|
|
253
221
|
it("matches a tween narrowed to this element by a selector the selection does not use", () => {
|
|
254
|
-
const groups = mountGroupSiblings();
|
|
222
|
+
const groups = mountGroupSiblings(5);
|
|
255
223
|
groups[2]!.id = "narrowed";
|
|
256
224
|
|
|
257
225
|
// The write half authored "#narrowed"; the read half still has ".group".
|
|
@@ -259,7 +227,7 @@ describe("tweenTargetsElement", () => {
|
|
|
259
227
|
});
|
|
260
228
|
|
|
261
229
|
it("does not hand an individual element the group tween it merely inherits", () => {
|
|
262
|
-
const groups = mountGroupSiblings();
|
|
230
|
+
const groups = mountGroupSiblings(5);
|
|
263
231
|
|
|
264
232
|
// Selecting one sibling by its own address must not let an edit mutate the
|
|
265
233
|
// ".group" tween: that write moves all five, not the one being nudged.
|
|
@@ -267,13 +235,13 @@ describe("tweenTargetsElement", () => {
|
|
|
267
235
|
});
|
|
268
236
|
|
|
269
237
|
it("still edits a group tween when the group itself is the selection", () => {
|
|
270
|
-
const groups = mountGroupSiblings();
|
|
238
|
+
const groups = mountGroupSiblings(5);
|
|
271
239
|
|
|
272
240
|
expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
|
|
273
241
|
});
|
|
274
242
|
|
|
275
243
|
it("does not match a target that is not a selector matches() understands", () => {
|
|
276
|
-
const groups = mountGroupSiblings();
|
|
244
|
+
const groups = mountGroupSiblings(5);
|
|
277
245
|
|
|
278
246
|
expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
|
|
279
247
|
});
|
|
@@ -281,8 +249,8 @@ describe("tweenTargetsElement", () => {
|
|
|
281
249
|
|
|
282
250
|
describe("commitKeyframeAtTimeImpl — no one-element target", () => {
|
|
283
251
|
it("drops the keyframe rather than authoring the bare class", async () => {
|
|
284
|
-
const groups = mountGroupSiblings();
|
|
285
|
-
const selection =
|
|
252
|
+
const groups = mountGroupSiblings(5);
|
|
253
|
+
const selection = stableSelectionFor(groups[3]!);
|
|
286
254
|
groups[3]!.remove();
|
|
287
255
|
const commitMutation = vi.fn(async () => undefined);
|
|
288
256
|
|
|
@@ -297,7 +265,7 @@ it("counts a keyframe-at-time conversion and insertion as one add gesture", asyn
|
|
|
297
265
|
const el = document.createElement("div");
|
|
298
266
|
el.id = "target";
|
|
299
267
|
document.body.append(el);
|
|
300
|
-
const selection =
|
|
268
|
+
const selection = stableSelectionFor(el);
|
|
301
269
|
const animations = parseGsapScript(
|
|
302
270
|
'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
|
|
303
271
|
).animations;
|