@hyperframes/studio 0.7.75 → 0.7.77
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-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -53,14 +53,87 @@ export function isInstantHold(animation: GsapAnimation): boolean {
|
|
|
53
53
|
* Returns `#id` if the selection has an id, otherwise the raw selector,
|
|
54
54
|
* or null if neither exists.
|
|
55
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* A CSS-valid selector for an element id. `#id` for a valid CSS identifier,
|
|
58
|
+
* otherwise an `[id="..."]` attribute selector. IDs that start with a digit
|
|
59
|
+
* (e.g. "01-hook-hero-word") make `#id` an invalid selector, so
|
|
60
|
+
* `document.querySelector("#01-...")` / GSAP's `querySelectorAll` throw a
|
|
61
|
+
* SyntaxError — which surfaces as a masked cross-origin "Script error." and
|
|
62
|
+
* crashes the preview the moment such a target is committed (e.g. dragging).
|
|
63
|
+
*/
|
|
64
|
+
// Conservative: matches only ids that are unquestionably safe as a `#id`
|
|
65
|
+
// selector — ASCII identifier, starts with a letter/underscore (or a single
|
|
66
|
+
// leading hyphen), no dots/colons/spaces/digits-first. Anything it rejects
|
|
67
|
+
// (digit-leading like "01-hook-...", dots, spaces, non-ASCII, …) falls through
|
|
68
|
+
// to the attribute selector below, which is always valid. It can only ever err
|
|
69
|
+
// toward the safe form, never toward a `#id` that throws — and, unlike
|
|
70
|
+
// `CSS.escape`, it needs no browser global (this runs in node tests too).
|
|
71
|
+
const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How close (in tween-%) a playhead has to be to count as sitting ON an existing
|
|
75
|
+
* keyframe. Every "is there already a keyframe here?" test shares this: with two
|
|
76
|
+
* different tolerances in play, one path decided "no keyframe here, append one"
|
|
77
|
+
* while another decided "yes, edit that one", and a drag near a waypoint left two
|
|
78
|
+
* keyframes a fraction of a percent apart.
|
|
79
|
+
*/
|
|
80
|
+
export const KEYFRAME_PCT_MATCH = 1;
|
|
81
|
+
|
|
82
|
+
export function idSelector(id: string): string {
|
|
83
|
+
// A `#id` selector is only valid for a CSS identifier. IDs that start with a
|
|
84
|
+
// digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
|
|
85
|
+
// GSAP's `querySelectorAll` throw a SyntaxError — surfacing as a masked
|
|
86
|
+
// cross-origin "Script error." that crashes the preview the moment such a
|
|
87
|
+
// target is committed (e.g. dragging the element). Address those via an
|
|
88
|
+
// attribute selector instead (quotes/backslashes escaped for the string).
|
|
89
|
+
return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Inverse of {@link idSelector}: the element id a target selector addresses, or
|
|
94
|
+
* null for a selector that is not id-based (a class, a tag, a descendant path).
|
|
95
|
+
*
|
|
96
|
+
* Both shapes have to be read back, not just `#id`. Every writer emits through
|
|
97
|
+
* `idSelector`, so a digit-leading, dotted or otherwise CSS-unsafe id lands in
|
|
98
|
+
* the source as `[id="01-hook-hero"]`. A reader that only matched `#id` saw no
|
|
99
|
+
* id at all for those elements and skipped them — which is how the post-commit
|
|
100
|
+
* keyframe-cache refresh silently stopped running for exactly the ids
|
|
101
|
+
* `idSelector` was added to support.
|
|
102
|
+
*/
|
|
103
|
+
export function idFromSelector(selector: string | undefined | null): string | null {
|
|
104
|
+
if (!selector) return null;
|
|
105
|
+
const hash = selector.match(/^#([\w-]+)/);
|
|
106
|
+
if (hash) return hash[1] ?? null;
|
|
107
|
+
const attribute = selector.match(/^\[id="((?:\\.|[^"\\])*)"\]/);
|
|
108
|
+
if (!attribute) return null;
|
|
109
|
+
// Undo the quote/backslash escaping idSelector applies.
|
|
110
|
+
return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1");
|
|
111
|
+
}
|
|
112
|
+
|
|
56
113
|
export function selectorFromSelection(selection: DomEditSelection): string | null {
|
|
57
|
-
if (selection.id) return
|
|
114
|
+
if (selection.id) return idSelector(selection.id);
|
|
58
115
|
if (selection.selector) return selection.selector;
|
|
59
116
|
return null;
|
|
60
117
|
}
|
|
61
118
|
|
|
62
119
|
// ── Percentage computation ────────────────────────────────────────────────────
|
|
63
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the timing basis used by editor keyframes. The timeline renders a
|
|
123
|
+
* duration-less tween across its owning clip, so mutations must use that same
|
|
124
|
+
* duration instead of silently falling back to GSAP's 0.5s default.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveEditableTweenDuration(
|
|
127
|
+
animation: GsapAnimation,
|
|
128
|
+
selection: DomEditSelection,
|
|
129
|
+
): number {
|
|
130
|
+
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
|
|
131
|
+
return resolveTweenDuration(
|
|
132
|
+
animation,
|
|
133
|
+
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
64
137
|
/**
|
|
65
138
|
* Compute the current playback percentage within an element's animation range.
|
|
66
139
|
* Uses the animation's resolved timing if available, otherwise falls back to
|
|
@@ -73,7 +146,7 @@ export function computeElementPercentage(
|
|
|
73
146
|
): number {
|
|
74
147
|
if (animation) {
|
|
75
148
|
const start = resolveTweenStart(animation);
|
|
76
|
-
const duration =
|
|
149
|
+
const duration = resolveEditableTweenDuration(animation, selection);
|
|
77
150
|
if (duration <= 0) return 0;
|
|
78
151
|
if (start !== null) {
|
|
79
152
|
return absoluteToPercentage(currentTime, start, duration);
|
|
@@ -81,9 +154,7 @@ export function computeElementPercentage(
|
|
|
81
154
|
}
|
|
82
155
|
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
|
83
156
|
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
|
84
|
-
return
|
|
85
|
-
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
|
86
|
-
: 0;
|
|
157
|
+
return absoluteToPercentage(currentTime, elStart, elDuration);
|
|
87
158
|
}
|
|
88
159
|
|
|
89
160
|
// ── Iframe accessors ──────────────────────────────────────────────────────────
|
|
@@ -118,6 +189,16 @@ export interface ParsedPercentageKeyframes {
|
|
|
118
189
|
easeEach?: string;
|
|
119
190
|
}
|
|
120
191
|
|
|
192
|
+
function collectAnimatableKeyframeProperties(entry: object): Record<string, number | string> {
|
|
193
|
+
const properties: Record<string, number | string> = {};
|
|
194
|
+
for (const [property, value] of Object.entries(entry)) {
|
|
195
|
+
if (property === "ease") continue;
|
|
196
|
+
if (typeof value === "number") properties[property] = Math.round(value * 1000) / 1000;
|
|
197
|
+
else if (typeof value === "string") properties[property] = value;
|
|
198
|
+
}
|
|
199
|
+
return properties;
|
|
200
|
+
}
|
|
201
|
+
|
|
121
202
|
/**
|
|
122
203
|
* Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`)
|
|
123
204
|
* into a sorted array of `{ percentage, properties }` entries.
|
|
@@ -146,12 +227,7 @@ export function parsePercentageKeyframes(
|
|
|
146
227
|
steps.forEach((entry, i) => {
|
|
147
228
|
if (!entry || typeof entry !== "object") return;
|
|
148
229
|
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
|
149
|
-
const properties
|
|
150
|
-
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
|
|
151
|
-
if (pk === "ease") continue;
|
|
152
|
-
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
|
153
|
-
else if (typeof pv === "string") properties[pk] = pv;
|
|
154
|
-
}
|
|
230
|
+
const properties = collectAnimatableKeyframeProperties(entry);
|
|
155
231
|
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
|
|
156
232
|
});
|
|
157
233
|
return keyframes.length > 0 ? { keyframes } : null;
|
|
@@ -165,12 +241,7 @@ export function parsePercentageKeyframes(
|
|
|
165
241
|
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
|
166
242
|
if (!pctMatch || !val || typeof val !== "object") continue;
|
|
167
243
|
const percentage = parseFloat(pctMatch[1]);
|
|
168
|
-
const properties
|
|
169
|
-
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
|
170
|
-
if (pk === "ease") continue;
|
|
171
|
-
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
|
172
|
-
else if (typeof pv === "string") properties[pk] = pv;
|
|
173
|
-
}
|
|
244
|
+
const properties = collectAnimatableKeyframeProperties(val);
|
|
174
245
|
if (Object.keys(properties).length > 0) {
|
|
175
246
|
keyframes.push({ percentage, properties });
|
|
176
247
|
}
|
|
@@ -187,3 +258,57 @@ export function parsePercentageKeyframes(
|
|
|
187
258
|
export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: number): number {
|
|
188
259
|
return tweenPos + (percentage / 100) * tweenDur;
|
|
189
260
|
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* An absolute time as a percentage of a timeline clip, at the one precision every
|
|
264
|
+
* keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered
|
|
265
|
+
* on the beat dot, and because selection keys embed this number, a writer that
|
|
266
|
+
* rounds coarser would orphan a live selection the moment it rewrites the cache.
|
|
267
|
+
* A zero-length clip has no percentage to give, so the tween-% passes through.
|
|
268
|
+
*/
|
|
269
|
+
export function toClipPercentage(
|
|
270
|
+
absoluteTime: number,
|
|
271
|
+
clipStart: number,
|
|
272
|
+
clipDuration: number,
|
|
273
|
+
fallbackPercentage: number,
|
|
274
|
+
): number {
|
|
275
|
+
if (clipDuration <= 0) return fallbackPercentage;
|
|
276
|
+
return Math.round(((absoluteTime - clipStart) / clipDuration) * 100000) / 1000;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One keyframe-cache row per tween keyframe: the percentage re-based onto the
|
|
281
|
+
* clip, the original tween percentage kept alongside it, and the animation
|
|
282
|
+
* identity every lane and selection key needs. Shared by the cache writers so
|
|
283
|
+
* they cannot drift in precision or in which identity fields they record.
|
|
284
|
+
*/
|
|
285
|
+
export function toClipKeyframes<T extends { percentage: number }>(
|
|
286
|
+
source: readonly T[],
|
|
287
|
+
anim: GsapAnimation,
|
|
288
|
+
clipStart: number,
|
|
289
|
+
clipDuration: number,
|
|
290
|
+
): Array<
|
|
291
|
+
T & {
|
|
292
|
+
tweenPercentage: number;
|
|
293
|
+
propertyGroup: GsapAnimation["propertyGroup"];
|
|
294
|
+
animationId: string;
|
|
295
|
+
}
|
|
296
|
+
> {
|
|
297
|
+
const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
298
|
+
// A duration-less tween spans the clip, the same rule the edit paths use
|
|
299
|
+
// (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
|
|
300
|
+
// percentage no editor agreed with.
|
|
301
|
+
const tweenDuration = anim.duration ?? clipDuration;
|
|
302
|
+
return source.map((keyframe) => ({
|
|
303
|
+
...keyframe,
|
|
304
|
+
percentage: toClipPercentage(
|
|
305
|
+
toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage),
|
|
306
|
+
clipStart,
|
|
307
|
+
clipDuration,
|
|
308
|
+
keyframe.percentage,
|
|
309
|
+
),
|
|
310
|
+
tweenPercentage: keyframe.percentage,
|
|
311
|
+
propertyGroup: anim.propertyGroup,
|
|
312
|
+
animationId: anim.id,
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
|
3
|
+
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
|
4
4
|
|
|
5
5
|
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
|
6
6
|
return {
|
|
@@ -53,3 +53,32 @@ describe("synthesizeFlatTweenKeyframes", () => {
|
|
|
53
53
|
expect(out).not.toBeNull();
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("deduplicateKeyframes ease ambiguity", () => {
|
|
58
|
+
it("flags a same-% collision from different animations (different eases)", () => {
|
|
59
|
+
const merged = deduplicateKeyframes([
|
|
60
|
+
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
|
61
|
+
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" },
|
|
62
|
+
]);
|
|
63
|
+
const kf = merged.find((k) => k.percentage === 45);
|
|
64
|
+
expect(kf?.easeAmbiguous).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("flags a cross-animation collision even when the raw eases match", () => {
|
|
68
|
+
// The button can still only target one arbitrary animation, and each may
|
|
69
|
+
// inherit a different easeEach/animation ease that raw comparison misses.
|
|
70
|
+
const merged = deduplicateKeyframes([
|
|
71
|
+
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
|
72
|
+
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" },
|
|
73
|
+
]);
|
|
74
|
+
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not flag a same-% collision within a single animation", () => {
|
|
78
|
+
const merged = deduplicateKeyframes([
|
|
79
|
+
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
|
80
|
+
{ percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" },
|
|
81
|
+
]);
|
|
82
|
+
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -5,15 +5,51 @@ import type {
|
|
|
5
5
|
} from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
)
|
|
11
|
-
|
|
8
|
+
/**
|
|
9
|
+
* A static position hold (only x/y, no real motion) is a `set`, not a keyframe —
|
|
10
|
+
* it must not synthesize a diamond. Covers both `tl.set(...)` and the
|
|
11
|
+
* `tl.to({ duration: 0, immediateRender: true })` hold that remove-all-keyframes
|
|
12
|
+
* collapses to (otherwise shown as a stray 0% keyframe).
|
|
13
|
+
*
|
|
14
|
+
* Single owner: the collapsed keyframe cache and the expanded property lanes'
|
|
15
|
+
* `gsapAnimations` map MUST agree on it, or a hold draws a phantom expanded lane
|
|
16
|
+
* with no matching collapsed diamond.
|
|
17
|
+
*/
|
|
18
|
+
export function isStaticPositionHold(anim: GsapAnimation): boolean {
|
|
19
|
+
if (anim.keyframes) return false;
|
|
20
|
+
if (anim.method !== "set" && (anim.duration ?? 0) !== 0) return false;
|
|
21
|
+
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
|
22
|
+
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function deduplicateKeyframes<
|
|
26
|
+
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
|
|
27
|
+
>(keyframes: T[]): T[] {
|
|
28
|
+
const byPct = new Map<number, T>();
|
|
12
29
|
for (const kf of keyframes) {
|
|
13
30
|
const existing = byPct.get(kf.percentage);
|
|
14
31
|
if (existing) {
|
|
15
32
|
existing.properties = { ...existing.properties, ...kf.properties };
|
|
16
|
-
|
|
33
|
+
// Two DIFFERENT source animations with a keyframe at the same clip %: a
|
|
34
|
+
// single inline ease button can only target one of them, and which one is
|
|
35
|
+
// arbitrary (each may also inherit a different easeEach/animation ease, so
|
|
36
|
+
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row
|
|
37
|
+
// hides the button there and the user edits per-lane instead.
|
|
38
|
+
if (
|
|
39
|
+
existing.animationId !== undefined &&
|
|
40
|
+
kf.animationId !== undefined &&
|
|
41
|
+
existing.animationId !== kf.animationId
|
|
42
|
+
) {
|
|
43
|
+
existing.easeAmbiguous = true;
|
|
44
|
+
}
|
|
45
|
+
// Whichever tween iterated last used to win `ease`, so the merged
|
|
46
|
+
// keyframe carried an arbitrary one of the colliding curves. Readers that
|
|
47
|
+
// do not check easeAmbiguous (drag readouts, lane hints) then showed a
|
|
48
|
+
// curve belonging to a different animation than the one an edit targets.
|
|
49
|
+
// Drop it instead: ambiguous means "no single ease", and the flag is the
|
|
50
|
+
// only honest answer.
|
|
51
|
+
if (existing.easeAmbiguous) delete existing.ease;
|
|
52
|
+
else if (kf.ease) existing.ease = kf.ease;
|
|
17
53
|
} else {
|
|
18
54
|
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
|
19
55
|
}
|
|
@@ -41,29 +77,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes
|
|
|
41
77
|
const fromProps = anim.fromProperties;
|
|
42
78
|
if (!toProps || Object.keys(toProps).length === 0) return null;
|
|
43
79
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
80
|
+
const rawStart: Record<string, number | string> = {};
|
|
81
|
+
const rawEnd: Record<string, number | string> = {};
|
|
46
82
|
|
|
47
83
|
if (anim.method === "from") {
|
|
48
84
|
for (const [k, v] of Object.entries(toProps)) {
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
rawStart[k] = v;
|
|
86
|
+
rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
51
87
|
}
|
|
52
88
|
} else if (anim.method === "fromTo" && fromProps) {
|
|
53
|
-
Object.assign(
|
|
54
|
-
Object.assign(
|
|
89
|
+
Object.assign(rawStart, fromProps);
|
|
90
|
+
Object.assign(rawEnd, toProps);
|
|
55
91
|
} else {
|
|
56
92
|
for (const [k, v] of Object.entries(toProps)) {
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
94
|
+
rawEnd[k] = v;
|
|
59
95
|
}
|
|
60
96
|
}
|
|
61
97
|
|
|
98
|
+
// Only numeric props are keyframe-interpolatable — a flat tween of a
|
|
99
|
+
// non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane.
|
|
100
|
+
const numericKeys = Object.keys(rawEnd).filter(
|
|
101
|
+
(k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number",
|
|
102
|
+
);
|
|
103
|
+
if (numericKeys.length === 0) return null;
|
|
104
|
+
const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]]));
|
|
105
|
+
const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]]));
|
|
106
|
+
|
|
62
107
|
return {
|
|
63
108
|
format: "percentage",
|
|
64
109
|
keyframes: [
|
|
65
110
|
{ percentage: 0, properties: startProps },
|
|
66
|
-
|
|
111
|
+
// Segment ease lives on the destination keyframe (Figma/AE model) so the
|
|
112
|
+
// lane + cache surface it; also kept data-level for useGsapTweenCache.
|
|
113
|
+
{ percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) },
|
|
67
114
|
],
|
|
68
115
|
...(anim.ease ? { ease: anim.ease } : {}),
|
|
69
116
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a composition file's GSAP tweens into the keyframe cache: fetch,
|
|
3
|
+
* selector -> element id resolution, and the clip-relative timing basis.
|
|
4
|
+
* Split from useGsapTweenCache to keep that file under the 600-line limit.
|
|
5
|
+
*/
|
|
6
|
+
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
7
|
+
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
|
8
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
9
|
+
import {
|
|
10
|
+
clearKeyframeCacheForFile,
|
|
11
|
+
elementCacheKeys,
|
|
12
|
+
writeGsapAnimationsForElement,
|
|
13
|
+
} from "./gsapKeyframeCacheHelpers";
|
|
14
|
+
import { idFromSelector, toClipKeyframes } from "./gsapShared";
|
|
15
|
+
import {
|
|
16
|
+
deduplicateKeyframes,
|
|
17
|
+
isStaticPositionHold,
|
|
18
|
+
synthesizeFlatTweenKeyframes,
|
|
19
|
+
} from "./gsapTweenSynth";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a tween's target selector to the ids of the element(s) it animates.
|
|
23
|
+
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
|
|
24
|
+
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
|
|
25
|
+
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
|
|
26
|
+
* element they animate — not just one parsed from the string. Falls back to a
|
|
27
|
+
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
|
|
28
|
+
*/
|
|
29
|
+
// fallow-ignore-next-line complexity
|
|
30
|
+
export function resolveSelectorElementIds(
|
|
31
|
+
selector: string,
|
|
32
|
+
doc: Document | null | undefined,
|
|
33
|
+
): string[] {
|
|
34
|
+
// A whole-selector id match (either shape) addresses exactly one element.
|
|
35
|
+
const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector)
|
|
36
|
+
? idFromSelector(selector)
|
|
37
|
+
: null;
|
|
38
|
+
if (bareId) return [bareId];
|
|
39
|
+
if (!doc) {
|
|
40
|
+
const lead = idFromSelector(selector);
|
|
41
|
+
return lead ? [lead] : [];
|
|
42
|
+
}
|
|
43
|
+
const ids = new Set<string>();
|
|
44
|
+
for (const part of selector.split(",")) {
|
|
45
|
+
const sel = part.trim();
|
|
46
|
+
if (!sel) continue;
|
|
47
|
+
try {
|
|
48
|
+
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
|
49
|
+
if (el.id) ids.add(el.id);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
const lead = idFromSelector(sel);
|
|
53
|
+
if (lead) ids.add(lead);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Array.from(ids);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The slice of the parse response callers actually read. The endpoint returns
|
|
60
|
+
* the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
|
|
61
|
+
* this fetch touches the source-text fields, so the guard below only has to
|
|
62
|
+
* vouch for what gets used.
|
|
63
|
+
*/
|
|
64
|
+
type ParsedGsapAnimations = Pick<
|
|
65
|
+
ParsedGsap,
|
|
66
|
+
"animations" | "multipleTimelines" | "unsupportedTimelinePattern"
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A proxy, an error page, or a stale server can answer 200 with something that
|
|
71
|
+
* has no `animations` array — the case where the old blind cast crashed on
|
|
72
|
+
* `.animations.filter`.
|
|
73
|
+
*/
|
|
74
|
+
function hasAnimations(value: unknown): value is ParsedGsapAnimations {
|
|
75
|
+
return (
|
|
76
|
+
typeof value === "object" &&
|
|
77
|
+
value !== null &&
|
|
78
|
+
"animations" in value &&
|
|
79
|
+
Array.isArray(value.animations)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function fetchParsedAnimations(
|
|
84
|
+
projectId: string,
|
|
85
|
+
sourceFile: string,
|
|
86
|
+
): Promise<ParsedGsapAnimations | null> {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(
|
|
89
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
|
90
|
+
// Always re-read the freshly-parsed source; no per-call timestamp (which
|
|
91
|
+
// would defeat caching forever and is a deterministic-render no-no).
|
|
92
|
+
{ cache: "no-store" },
|
|
93
|
+
);
|
|
94
|
+
if (!res.ok) return null;
|
|
95
|
+
const parsed: unknown = await res.json();
|
|
96
|
+
if (!hasAnimations(parsed)) return null;
|
|
97
|
+
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
|
|
98
|
+
// hold an element's first keyframe before its tween). They must not surface as
|
|
99
|
+
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
|
|
100
|
+
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
|
108
|
+
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
|
109
|
+
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
|
110
|
+
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
|
111
|
+
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
|
112
|
+
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
|
113
|
+
*/
|
|
114
|
+
export function resolveClipTimingBasis(
|
|
115
|
+
elementId: string,
|
|
116
|
+
sourceFile: string,
|
|
117
|
+
elements: ReadonlyArray<{
|
|
118
|
+
domId?: string;
|
|
119
|
+
key?: string;
|
|
120
|
+
id: string;
|
|
121
|
+
start: number;
|
|
122
|
+
duration: number;
|
|
123
|
+
}>,
|
|
124
|
+
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
|
125
|
+
): { elStart: number; elDuration: number } {
|
|
126
|
+
const direct = elements.find(
|
|
127
|
+
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
128
|
+
);
|
|
129
|
+
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
|
130
|
+
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
|
131
|
+
const host = hostId
|
|
132
|
+
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
|
133
|
+
: undefined;
|
|
134
|
+
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read one composition file's tweens into the keyframe cache. Split out of the
|
|
139
|
+
* hook so the effect can run it per file without re-nesting the whole body.
|
|
140
|
+
*/
|
|
141
|
+
// fallow-ignore-next-line complexity
|
|
142
|
+
export async function populateKeyframeCacheFromAst(
|
|
143
|
+
projectId: string,
|
|
144
|
+
sf: string,
|
|
145
|
+
doc: Document | null | undefined,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const parsed = await fetchParsedAnimations(projectId, sf);
|
|
148
|
+
if (!parsed) return;
|
|
149
|
+
const { setKeyframeCache } = usePlayerStore.getState();
|
|
150
|
+
clearKeyframeCacheForFile(sf);
|
|
151
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
152
|
+
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
153
|
+
const sourceByElement = new Map<string, GsapAnimation[]>();
|
|
154
|
+
for (const anim of parsed.animations) {
|
|
155
|
+
if (anim.hasUnresolvedKeyframes) continue;
|
|
156
|
+
if (isStaticPositionHold(anim)) continue;
|
|
157
|
+
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
158
|
+
if (!kfData) continue;
|
|
159
|
+
// Attribute the tween to every element it animates (handles class /
|
|
160
|
+
// group / descendant selectors, not just `#id`).
|
|
161
|
+
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
|
162
|
+
// kfData is already resolved (real keyframes OR a synthesized flat
|
|
163
|
+
// tween), so a flat tween joins the store like a keyframed one. No
|
|
164
|
+
// property-group filter: this map must cover every tween the cache
|
|
165
|
+
// below records, or expanded lanes have nothing to render.
|
|
166
|
+
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
|
|
167
|
+
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
|
168
|
+
const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration);
|
|
169
|
+
const existing = mergedByElement.get(id);
|
|
170
|
+
if (existing) {
|
|
171
|
+
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
|
172
|
+
} else {
|
|
173
|
+
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const [id, kfData] of mergedByElement) {
|
|
178
|
+
for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
|
|
179
|
+
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
15
|
+
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
|
15
16
|
|
|
16
17
|
afterEach(() => {
|
|
17
18
|
usePlayerStore.getState().reset();
|
|
@@ -342,4 +343,83 @@ describe("deleteSelectedKeyframes", () => {
|
|
|
342
343
|
expect(options[1]).not.toHaveProperty("softReload");
|
|
343
344
|
expect(options[2]).not.toHaveProperty("skipReload");
|
|
344
345
|
});
|
|
346
|
+
|
|
347
|
+
it("deletes two expanded lanes through their own animation and tween percentages", () => {
|
|
348
|
+
usePlayerStore.setState({
|
|
349
|
+
selectedElementId: "card",
|
|
350
|
+
selectedKeyframes: new Set([
|
|
351
|
+
timelineKeyframeSelectionKey("card", {
|
|
352
|
+
percentage: 30,
|
|
353
|
+
tweenPercentage: 20,
|
|
354
|
+
propertyGroup: "position",
|
|
355
|
+
animationId: "card-position",
|
|
356
|
+
}),
|
|
357
|
+
timelineKeyframeSelectionKey("card", {
|
|
358
|
+
percentage: 70,
|
|
359
|
+
tweenPercentage: 80,
|
|
360
|
+
propertyGroup: "visual",
|
|
361
|
+
animationId: "card-visual",
|
|
362
|
+
}),
|
|
363
|
+
]),
|
|
364
|
+
});
|
|
365
|
+
const handleGsapRemoveKeyframe =
|
|
366
|
+
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
|
367
|
+
|
|
368
|
+
deleteSelectedKeyframes({
|
|
369
|
+
selectedGsapAnimations: [
|
|
370
|
+
{ id: "card-position", keyframes: {} },
|
|
371
|
+
{ id: "card-visual", keyframes: {} },
|
|
372
|
+
],
|
|
373
|
+
handleGsapRemoveKeyframe,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
|
|
377
|
+
expect(
|
|
378
|
+
handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
|
|
379
|
+
animationId,
|
|
380
|
+
percentage,
|
|
381
|
+
]),
|
|
382
|
+
).toEqual([
|
|
383
|
+
["card-position", 20],
|
|
384
|
+
["card-visual", 80],
|
|
385
|
+
]);
|
|
386
|
+
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
|
|
387
|
+
expect.objectContaining({ skipReload: true }),
|
|
388
|
+
);
|
|
389
|
+
expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
|
|
390
|
+
expect.objectContaining({ softReload: true }),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("drops keyframes that belong to other elements", () => {
|
|
395
|
+
// A stale selection from a previously active element must not delete
|
|
396
|
+
// anything on the element that is active now.
|
|
397
|
+
usePlayerStore.setState({
|
|
398
|
+
selectedElementId: "card",
|
|
399
|
+
selectedKeyframes: new Set([
|
|
400
|
+
timelineKeyframeSelectionKey("card", {
|
|
401
|
+
percentage: 30,
|
|
402
|
+
tweenPercentage: 20,
|
|
403
|
+
propertyGroup: "position",
|
|
404
|
+
animationId: "card-position",
|
|
405
|
+
}),
|
|
406
|
+
timelineKeyframeSelectionKey("other", {
|
|
407
|
+
percentage: 70,
|
|
408
|
+
tweenPercentage: 80,
|
|
409
|
+
propertyGroup: "position",
|
|
410
|
+
animationId: "card-position",
|
|
411
|
+
}),
|
|
412
|
+
]),
|
|
413
|
+
});
|
|
414
|
+
const handleGsapRemoveKeyframe =
|
|
415
|
+
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
|
416
|
+
|
|
417
|
+
deleteSelectedKeyframes({
|
|
418
|
+
selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
|
|
419
|
+
handleGsapRemoveKeyframe,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(1);
|
|
423
|
+
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
|
|
424
|
+
});
|
|
345
425
|
});
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
TimelineGroupMoveChange,
|
|
5
5
|
} from "./useTimelineGroupEditing";
|
|
6
6
|
|
|
7
|
-
interface
|
|
7
|
+
export interface TimelineMoveEdit {
|
|
8
8
|
element: TimelineElement;
|
|
9
9
|
updates: Pick<TimelineElement, "start" | "track">;
|
|
10
10
|
}
|
|
@@ -18,8 +18,15 @@ interface AtomicMoveDeps {
|
|
|
18
18
|
|
|
19
19
|
export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
|
|
20
20
|
|
|
21
|
+
export type TimelineMoveEditsHandler = (
|
|
22
|
+
edits: TimelineMoveEdit[],
|
|
23
|
+
coalesceKey?: string,
|
|
24
|
+
operation?: TimelineMoveOperation,
|
|
25
|
+
coalesceMs?: number,
|
|
26
|
+
) => Promise<void>;
|
|
27
|
+
|
|
21
28
|
export function persistTimelineMoveEditsAtomically(
|
|
22
|
-
edits:
|
|
29
|
+
edits: TimelineMoveEdit[],
|
|
23
30
|
coalesceKey: string | undefined,
|
|
24
31
|
operation: TimelineMoveOperation,
|
|
25
32
|
deps: AtomicMoveDeps,
|
|
@@ -92,14 +92,14 @@ export interface UseDomEditWiringParams {
|
|
|
92
92
|
animId: string,
|
|
93
93
|
fromPercentage: number,
|
|
94
94
|
toPercentage: number,
|
|
95
|
-
) =>
|
|
95
|
+
) => Promise<boolean>;
|
|
96
96
|
resizeKeyframedTween: (
|
|
97
97
|
sel: DomEditSelection,
|
|
98
98
|
animId: string,
|
|
99
99
|
position: number,
|
|
100
100
|
duration: number,
|
|
101
101
|
pctRemap: Array<{ from: number; to: number }>,
|
|
102
|
-
) =>
|
|
102
|
+
) => Promise<boolean>;
|
|
103
103
|
convertToKeyframes: (
|
|
104
104
|
sel: DomEditSelection,
|
|
105
105
|
animId: string,
|