@hyperframes/studio 0.6.122-alpha.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-i-MLqhmm.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -8,13 +8,19 @@
|
|
|
8
8
|
* Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback } from "react";
|
|
11
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
15
15
|
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
|
|
16
|
+
import {
|
|
17
|
+
resolveTweenStart,
|
|
18
|
+
resolveTweenDuration,
|
|
19
|
+
isTimeWithinTween,
|
|
20
|
+
} from "../utils/globalTimeCompiler";
|
|
16
21
|
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
17
22
|
import { roundTo3 } from "../utils/rounding";
|
|
23
|
+
import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
|
|
18
24
|
|
|
19
25
|
export interface EnableKeyframesSession {
|
|
20
26
|
domEditSelection: DomEditSelection | null;
|
|
@@ -37,6 +43,70 @@ export interface EnableKeyframesSession {
|
|
|
37
43
|
) => Promise<void>;
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Which animated properties to capture from the live element. Array-form keyframe
|
|
48
|
+
* tweens (`keyframes: [{x,y},…]`) leave `anim.properties` empty — the props live in
|
|
49
|
+
* the keyframe stops — so fall back to the union of the stops' keys, then to x/y.
|
|
50
|
+
*/
|
|
51
|
+
export function animatedProps(anim: GsapAnimation | null): string[] {
|
|
52
|
+
if (!anim) return ["x", "y"];
|
|
53
|
+
const own = Object.keys(anim.properties ?? {});
|
|
54
|
+
if (own.length > 0) return own;
|
|
55
|
+
const stops = anim.keyframes?.keyframes;
|
|
56
|
+
if (stops?.length) {
|
|
57
|
+
const keys = new Set<string>();
|
|
58
|
+
for (const stop of stops) for (const k of Object.keys(stop.properties ?? {})) keys.add(k);
|
|
59
|
+
if (keys.size > 0) return [...keys];
|
|
60
|
+
}
|
|
61
|
+
return ["x", "y"];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether the playhead sits inside an animation's tween range. When the tween's
|
|
66
|
+
* start can't be resolved we don't block (the percentage falls back to clip range,
|
|
67
|
+
* preserving prior behavior for elements without explicit timing).
|
|
68
|
+
*/
|
|
69
|
+
export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean {
|
|
70
|
+
const start = resolveTweenStart(anim);
|
|
71
|
+
if (start === null) return true;
|
|
72
|
+
return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Grow a keyframe tween's range to reach a playhead that sits outside it, and add a
|
|
77
|
+
* keyframe there. Existing keyframes keep their *absolute* timing (percentages
|
|
78
|
+
* rescale into the new range), so the current motion is preserved — the playhead
|
|
79
|
+
* just becomes a new hold at the start or end. Used when "add keyframe at playhead"
|
|
80
|
+
* fires beyond the tween instead of disabling the action.
|
|
81
|
+
*/
|
|
82
|
+
export function buildExtendedKeyframes(
|
|
83
|
+
anim: GsapAnimation,
|
|
84
|
+
currentTime: number,
|
|
85
|
+
position: Record<string, number>,
|
|
86
|
+
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
|
87
|
+
const oldStart = resolveTweenStart(anim) ?? 0;
|
|
88
|
+
const oldDuration = resolveTweenDuration(anim);
|
|
89
|
+
const newStart = Math.min(oldStart, currentTime);
|
|
90
|
+
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
|
91
|
+
const newDuration = roundTo3(newEnd - newStart);
|
|
92
|
+
const toPct = (absoluteTime: number) =>
|
|
93
|
+
newDuration > 0
|
|
94
|
+
? Math.max(
|
|
95
|
+
0,
|
|
96
|
+
Math.min(100, Math.round(((absoluteTime - newStart) / newDuration) * 1000) / 10),
|
|
97
|
+
)
|
|
98
|
+
: 0;
|
|
99
|
+
const stops = anim.keyframes?.keyframes ?? [];
|
|
100
|
+
const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
|
|
101
|
+
percentage: toPct(oldStart + (stop.percentage / 100) * oldDuration),
|
|
102
|
+
properties: stop.properties,
|
|
103
|
+
...(stop.ease ? { ease: stop.ease } : {}),
|
|
104
|
+
}));
|
|
105
|
+
const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
|
|
106
|
+
const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
|
|
107
|
+
return { position: roundTo3(newStart), duration: newDuration, keyframes };
|
|
108
|
+
}
|
|
109
|
+
|
|
40
110
|
function readElementPosition(
|
|
41
111
|
iframe: HTMLIFrameElement | null,
|
|
42
112
|
sel: DomEditSelection,
|
|
@@ -55,7 +125,9 @@ function readElementPosition(
|
|
|
55
125
|
const element = sel.element;
|
|
56
126
|
if (!element?.isConnected || !gsap?.getProperty) return result;
|
|
57
127
|
|
|
58
|
-
|
|
128
|
+
// ponytail: a brand-new tween captures position only — bundling opacity made it
|
|
129
|
+
// a mixed group that the position-only drag intercept couldn't resolve.
|
|
130
|
+
const props = animatedProps(anim);
|
|
59
131
|
for (const prop of props) {
|
|
60
132
|
const val = Number(gsap.getProperty(element, prop));
|
|
61
133
|
if (!Number.isFinite(val)) continue;
|
|
@@ -65,18 +137,201 @@ function readElementPosition(
|
|
|
65
137
|
return result;
|
|
66
138
|
}
|
|
67
139
|
|
|
68
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Range for a brand-new keyframe tween created via "Enable keyframes" on an element
|
|
142
|
+
* with no existing animation. "Add a keyframe" must land at the PLAYHEAD.
|
|
143
|
+
*
|
|
144
|
+
* The runtime auto-stamps `data-start="0"` + `data-duration=<rootDuration>` on every
|
|
145
|
+
* timeline element, so we can't treat `data-start` as authored timing (doing so put
|
|
146
|
+
* the keyframe at 0). Instead, clamp the playhead into the element's [start, end]
|
|
147
|
+
* range: the auto-stamp's full-composition range passes the playhead through
|
|
148
|
+
* unchanged, while a genuinely narrow authored clip still clamps sensibly.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveNewTweenRange(
|
|
151
|
+
authoredStart: string | undefined,
|
|
152
|
+
authoredDuration: string | undefined,
|
|
153
|
+
currentTime: number,
|
|
154
|
+
): { start: number; duration: number } {
|
|
155
|
+
const t = Math.max(0, roundTo3(currentTime));
|
|
156
|
+
const start = authoredStart != null ? Number.parseFloat(authoredStart) : Number.NaN;
|
|
157
|
+
const duration = authoredDuration != null ? Number.parseFloat(authoredDuration) : Number.NaN;
|
|
158
|
+
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) {
|
|
159
|
+
return { start: t, duration: 1 };
|
|
160
|
+
}
|
|
161
|
+
const end = start + duration;
|
|
162
|
+
const clampedStart = Math.min(Math.max(t, start), end);
|
|
163
|
+
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Authoritative parse of the current source for `sel`. Returns `null` when the
|
|
167
|
+
// fetch can't run (no projectId / request failed) so callers can distinguish
|
|
168
|
+
// "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
|
|
169
|
+
// array means the source was read and the element has no animations.
|
|
170
|
+
async function tryFetchAnimationsForElement(
|
|
171
|
+
sel: DomEditSelection,
|
|
172
|
+
): Promise<GsapAnimation[] | null> {
|
|
69
173
|
const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
|
|
70
|
-
if (!projectId) return
|
|
174
|
+
if (!projectId) return null;
|
|
71
175
|
const sourceFile = sel.sourceFile || "index.html";
|
|
72
176
|
const parsed = await fetchParsedAnimations(projectId, sourceFile);
|
|
73
|
-
if (!parsed) return
|
|
177
|
+
if (!parsed) return null;
|
|
74
178
|
return getAnimationsForElement(parsed.animations, {
|
|
75
179
|
id: sel.id,
|
|
76
180
|
selector: sel.selector,
|
|
77
181
|
});
|
|
78
182
|
}
|
|
79
183
|
|
|
184
|
+
async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
|
|
185
|
+
return (await tryFetchAnimationsForElement(sel)) ?? [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
|
|
190
|
+
* toggle off an existing stop, add one at the playhead's tween-relative %, or —
|
|
191
|
+
* when the playhead sits outside the tween — extend the range to reach it (see
|
|
192
|
+
* buildExtendedKeyframes). Shared by native keyframe tweens and flat tweens that
|
|
193
|
+
* were just converted, so both behave identically.
|
|
194
|
+
*/
|
|
195
|
+
async function applyKeyframeAtPlayhead(
|
|
196
|
+
session: EnableKeyframesSession,
|
|
197
|
+
sel: DomEditSelection,
|
|
198
|
+
kfAnim: GsapAnimation,
|
|
199
|
+
t: number,
|
|
200
|
+
iframe: HTMLIFrameElement | null,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
if (!isPlayheadWithinTween(kfAnim, t)) {
|
|
203
|
+
const position = readElementPosition(iframe, sel, kfAnim);
|
|
204
|
+
const selector = selectorFromSelection(sel);
|
|
205
|
+
if (selector && Object.keys(position).length > 0 && session.commitMutation) {
|
|
206
|
+
const extended = buildExtendedKeyframes(kfAnim, t, position);
|
|
207
|
+
await session.commitMutation(
|
|
208
|
+
{
|
|
209
|
+
type: "replace-with-keyframes",
|
|
210
|
+
animationId: kfAnim.id,
|
|
211
|
+
targetSelector: selector,
|
|
212
|
+
position: extended.position,
|
|
213
|
+
duration: extended.duration,
|
|
214
|
+
keyframes: extended.keyframes,
|
|
215
|
+
ease: kfAnim.ease,
|
|
216
|
+
},
|
|
217
|
+
{ label: "Add keyframe", softReload: true },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const pct = computeElementPercentage(t, sel, kfAnim);
|
|
223
|
+
const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
|
224
|
+
if (existing) {
|
|
225
|
+
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (session.handleGsapAddKeyframeBatch) {
|
|
229
|
+
const position = readElementPosition(iframe, sel, kfAnim);
|
|
230
|
+
if (Object.keys(position).length > 0) {
|
|
231
|
+
await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A set() is an instantaneous hold. "Add keyframe at playhead" promotes it to a
|
|
238
|
+
* two-stop tween from the set's time to the playhead — the held value at 0%, the
|
|
239
|
+
* live value at 100% — giving the user something to animate. No-op if the playhead
|
|
240
|
+
* is at or before the set.
|
|
241
|
+
*/
|
|
242
|
+
async function promoteSetToKeyframes(
|
|
243
|
+
session: EnableKeyframesSession,
|
|
244
|
+
sel: DomEditSelection,
|
|
245
|
+
setAnim: GsapAnimation,
|
|
246
|
+
t: number,
|
|
247
|
+
iframe: HTMLIFrameElement | null,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const selector = selectorFromSelection(sel);
|
|
250
|
+
const setStart = resolveTweenStart(setAnim) ?? 0;
|
|
251
|
+
if (!selector || !session.commitMutation || t <= setStart) return;
|
|
252
|
+
const endPosition = readElementPosition(iframe, sel, setAnim);
|
|
253
|
+
if (Object.keys(endPosition).length === 0) return;
|
|
254
|
+
const startPosition: Record<string, number> = {};
|
|
255
|
+
for (const key of Object.keys(endPosition)) {
|
|
256
|
+
const held = setAnim.properties?.[key];
|
|
257
|
+
if (typeof held === "number") startPosition[key] = held;
|
|
258
|
+
}
|
|
259
|
+
await session.commitMutation(
|
|
260
|
+
{
|
|
261
|
+
type: "replace-with-keyframes",
|
|
262
|
+
animationId: setAnim.id,
|
|
263
|
+
targetSelector: selector,
|
|
264
|
+
position: roundTo3(setStart),
|
|
265
|
+
duration: roundTo3(t - setStart),
|
|
266
|
+
keyframes: [
|
|
267
|
+
{
|
|
268
|
+
percentage: 0,
|
|
269
|
+
properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
|
|
270
|
+
},
|
|
271
|
+
{ percentage: 100, properties: endPosition },
|
|
272
|
+
],
|
|
273
|
+
ease: setAnim.ease,
|
|
274
|
+
},
|
|
275
|
+
{ label: "Add keyframe", softReload: true },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so
|
|
281
|
+
* it must be edited as waypoints (not x/y keyframes, which would break the curve).
|
|
282
|
+
* "Add keyframe at playhead" drops a waypoint where the element currently sits on
|
|
283
|
+
* the path, inserted at the matching segment so the curve is preserved. Outside the
|
|
284
|
+
* range, extend the duration so the motion reaches the playhead.
|
|
285
|
+
*/
|
|
286
|
+
async function applyArcWaypointAtPlayhead(
|
|
287
|
+
session: EnableKeyframesSession,
|
|
288
|
+
sel: DomEditSelection,
|
|
289
|
+
arcAnim: GsapAnimation,
|
|
290
|
+
t: number,
|
|
291
|
+
iframe: HTMLIFrameElement | null,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
if (!session.commitMutation) return;
|
|
294
|
+
if (!isPlayheadWithinTween(arcAnim, t)) {
|
|
295
|
+
const start = resolveTweenStart(arcAnim) ?? 0;
|
|
296
|
+
if (t > start) {
|
|
297
|
+
await session.commitMutation(
|
|
298
|
+
{
|
|
299
|
+
type: "update-meta",
|
|
300
|
+
animationId: arcAnim.id,
|
|
301
|
+
updates: { duration: roundTo3(t - start) },
|
|
302
|
+
},
|
|
303
|
+
{ label: "Extend motion path", softReload: true },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const live = readElementPosition(iframe, sel, arcAnim);
|
|
309
|
+
if (typeof live.x !== "number" || typeof live.y !== "number") return;
|
|
310
|
+
const liveX = live.x;
|
|
311
|
+
const liveY = live.y;
|
|
312
|
+
const nodes = (arcAnim.keyframes?.keyframes ?? [])
|
|
313
|
+
.map((k) => ({ x: k.properties.x, y: k.properties.y }))
|
|
314
|
+
.filter(
|
|
315
|
+
(p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
|
|
316
|
+
);
|
|
317
|
+
// Don't duplicate a waypoint that already sits where the element is (e.g. at the
|
|
318
|
+
// path endpoints).
|
|
319
|
+
const WAYPOINT_MERGE_PX = 6;
|
|
320
|
+
if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
|
|
321
|
+
const proj = nearestPointOnPath(liveX, liveY, nodes);
|
|
322
|
+
if (!proj) return;
|
|
323
|
+
await session.commitMutation(
|
|
324
|
+
{
|
|
325
|
+
type: "add-motion-path-point",
|
|
326
|
+
animationId: arcAnim.id,
|
|
327
|
+
index: proj.segIndex + 1,
|
|
328
|
+
x: liveX,
|
|
329
|
+
y: liveY,
|
|
330
|
+
},
|
|
331
|
+
{ label: "Add waypoint", softReload: true },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
80
335
|
// fallow-ignore-next-line complexity
|
|
81
336
|
export function useEnableKeyframes(
|
|
82
337
|
sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
|
|
@@ -90,41 +345,45 @@ export function useEnableKeyframes(
|
|
|
90
345
|
const t = usePlayerStore.getState().currentTime;
|
|
91
346
|
const iframe = session.previewIframeRef?.current ?? null;
|
|
92
347
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const pct = computeElementPercentage(t, sel);
|
|
103
|
-
const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
|
104
|
-
if (existing) {
|
|
105
|
-
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
106
|
-
} else if (session.handleGsapAddKeyframeBatch) {
|
|
107
|
-
const position = readElementPosition(iframe, sel, kfAnim);
|
|
108
|
-
if (Object.keys(position).length > 0) {
|
|
109
|
-
await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
} else if (flatAnim) {
|
|
113
|
-
const position = readElementPosition(iframe, sel, flatAnim);
|
|
114
|
-
const hasPosition = Object.keys(position).length > 0;
|
|
348
|
+
// `selectedGsapAnimations` is a studio-side selection cache that can lag a
|
|
349
|
+
// mutation — e.g. right after a delete-all it may still hold the just-removed
|
|
350
|
+
// tween, which would route us into the wrong branch below (editing a tween
|
|
351
|
+
// that no longer exists in source). Prefer the authoritative parse of the
|
|
352
|
+
// current source; an empty parse is a valid "no animations" result and is
|
|
353
|
+
// honored. Fall back to the cache only when the fetch couldn't run at all
|
|
354
|
+
// (no projectId / request failed), preserving prior behavior offline.
|
|
355
|
+
const fetched = await tryFetchAnimationsForElement(sel);
|
|
356
|
+
const anims = fetched ?? session.selectedGsapAnimations;
|
|
115
357
|
|
|
116
|
-
|
|
358
|
+
// An arc/motionPath tween carries reconstructed x/y keyframes too, so match it
|
|
359
|
+
// first and edit it as waypoints — treating it as plain keyframes would break
|
|
360
|
+
// the curve.
|
|
361
|
+
const arcAnim = anims.find((a) => a.arcPath);
|
|
362
|
+
const kfAnim = anims.find((a) => a.keyframes && !a.arcPath);
|
|
363
|
+
const setAnim = anims.find((a) => a.method === "set" && !a.keyframes && !a.arcPath);
|
|
364
|
+
const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && a.method !== "set");
|
|
117
365
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
366
|
+
if (arcAnim) {
|
|
367
|
+
await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
|
|
368
|
+
} else if (kfAnim) {
|
|
369
|
+
await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
|
|
370
|
+
} else if (setAnim) {
|
|
371
|
+
await promoteSetToKeyframes(session, sel, setAnim, t, iframe);
|
|
372
|
+
} else if (flatAnim) {
|
|
373
|
+
// Convert the flat tween (to/from/fromTo) to its natural keyframes — no
|
|
374
|
+
// resolvedFromValues, so the 0%/100% stops keep the real start→end motion
|
|
375
|
+
// (passing the playhead value would flatten it). Then apply uniformly so an
|
|
376
|
+
// out-of-range playhead extends the range just like a keyframe tween.
|
|
377
|
+
await session.handleGsapConvertToKeyframes(flatAnim.id);
|
|
378
|
+
const converted = (await fetchAnimationsForElement(sel)).find((a) => a.keyframes);
|
|
379
|
+
if (converted) await applyKeyframeAtPlayhead(session, sel, converted, t, iframe);
|
|
123
380
|
} else {
|
|
124
381
|
const position = readElementPosition(iframe, sel, null);
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
382
|
+
const { start: elStart, duration: elDuration } = resolveNewTweenRange(
|
|
383
|
+
sel.dataAttributes?.start,
|
|
384
|
+
sel.dataAttributes?.duration,
|
|
385
|
+
t,
|
|
386
|
+
);
|
|
128
387
|
const selector = selectorFromSelection(sel);
|
|
129
388
|
|
|
130
389
|
if (!selector) {
|
|
@@ -135,19 +394,13 @@ export function useEnableKeyframes(
|
|
|
135
394
|
if (Object.keys(position).length === 0) {
|
|
136
395
|
position.x = 0;
|
|
137
396
|
position.y = 0;
|
|
138
|
-
position.opacity = 1;
|
|
139
397
|
}
|
|
140
398
|
|
|
399
|
+
// One keyframe at the playhead — a single diamond capturing the current
|
|
400
|
+
// value. Motion comes from the user adding/dragging more keyframes later;
|
|
401
|
+
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
|
|
141
402
|
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
142
403
|
[{ percentage: 0, properties: { ...position } }];
|
|
143
|
-
if (pct > 1 && pct < 99) {
|
|
144
|
-
keyframes.push({ percentage: pct, properties: { ...position } });
|
|
145
|
-
}
|
|
146
|
-
keyframes.push({
|
|
147
|
-
percentage: 100,
|
|
148
|
-
properties: { ...position },
|
|
149
|
-
auto: true,
|
|
150
|
-
} as (typeof keyframes)[number]);
|
|
151
404
|
|
|
152
405
|
if (session.commitMutation) {
|
|
153
406
|
await session.commitMutation(
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Extracted from App.tsx to keep file sizes under the 600-line limit.
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
6
|
+
import { editLog } from "../utils/editDebugLog";
|
|
6
7
|
import { useGestureRecording } from "./useGestureRecording";
|
|
7
8
|
import { simplifyGestureSamples } from "../utils/rdpSimplify";
|
|
8
9
|
import { usePlayerStore } from "../player";
|
|
@@ -11,6 +12,46 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
11
12
|
import { roundTo3 } from "../utils/rounding";
|
|
12
13
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
13
14
|
|
|
15
|
+
type RecordedKeyframe = { percentage: number; properties: Record<string, number | string> };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Split recorded keyframes into one keyframe-set per property group (position /
|
|
19
|
+
* scale / rotation / …), each keyframe carrying only that group's props.
|
|
20
|
+
*
|
|
21
|
+
* A mixed-prop gesture (e.g. x/y + opacity) emitted as ONE add-with-keyframes
|
|
22
|
+
* mutation parses back as an untagged legacy mixed tween, which breaks the
|
|
23
|
+
* position-only drag intercept (it can't find a pure position tween to edit).
|
|
24
|
+
* Emitting one tween per group keeps the position tween tagged and editable.
|
|
25
|
+
* Keyframes with no prop in a group are dropped from that group's set.
|
|
26
|
+
*/
|
|
27
|
+
function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] {
|
|
28
|
+
// Preserve first-seen group order for deterministic, stable mutation ordering.
|
|
29
|
+
const groupOrder: string[] = [];
|
|
30
|
+
const byGroup = new Map<string, RecordedKeyframe[]>();
|
|
31
|
+
for (const kf of keyframes) {
|
|
32
|
+
const perGroup = new Map<string, Record<string, number | string>>();
|
|
33
|
+
for (const [key, value] of Object.entries(kf.properties)) {
|
|
34
|
+
const group = classifyPropertyGroup(key);
|
|
35
|
+
let props = perGroup.get(group);
|
|
36
|
+
if (!props) {
|
|
37
|
+
props = {};
|
|
38
|
+
perGroup.set(group, props);
|
|
39
|
+
}
|
|
40
|
+
props[key] = value;
|
|
41
|
+
}
|
|
42
|
+
for (const [group, props] of perGroup) {
|
|
43
|
+
let set = byGroup.get(group);
|
|
44
|
+
if (!set) {
|
|
45
|
+
set = [];
|
|
46
|
+
byGroup.set(group, set);
|
|
47
|
+
groupOrder.push(group);
|
|
48
|
+
}
|
|
49
|
+
set.push({ percentage: kf.percentage, properties: props });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return groupOrder.map((group) => byGroup.get(group)!);
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
// Minimal subset of the session used by gesture commit
|
|
15
56
|
interface GestureSessionRef {
|
|
16
57
|
domEditSelection: DomEditSelection | null;
|
|
@@ -121,76 +162,100 @@ export function useGestureCommit({
|
|
|
121
162
|
? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
|
|
122
163
|
: undefined;
|
|
123
164
|
if (existingPositionTween) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const recEnd = recStart + duration;
|
|
128
|
-
|
|
129
|
-
// Only merge if the recording overlaps the existing tween's time range.
|
|
130
|
-
// No overlap → fall through to add-with-keyframes (creates a separate tween).
|
|
131
|
-
const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
|
|
132
|
-
|
|
133
|
-
if (overlaps) {
|
|
134
|
-
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
|
|
135
|
-
const rangeStartPct =
|
|
136
|
-
tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
|
|
137
|
-
const rangeEndPct =
|
|
138
|
-
tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
|
|
139
|
-
|
|
140
|
-
const preserved = existingKfs
|
|
141
|
-
.filter(
|
|
142
|
-
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
|
|
143
|
-
)
|
|
144
|
-
.map((kf) => ({
|
|
145
|
-
percentage: kf.percentage,
|
|
146
|
-
properties: kf.properties,
|
|
147
|
-
...(kf.ease ? { ease: kf.ease } : {}),
|
|
148
|
-
}));
|
|
149
|
-
|
|
150
|
-
const mapped = keyframes.map((kf) => ({
|
|
151
|
-
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
|
|
152
|
-
properties: kf.properties,
|
|
153
|
-
}));
|
|
154
|
-
|
|
155
|
-
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
|
|
156
|
-
|
|
165
|
+
if (existingPositionTween.method === "set") {
|
|
166
|
+
// A `set` is a static hold, not a tween to merge into — replace it with
|
|
167
|
+
// the recorded motion (which already starts from the set's position).
|
|
157
168
|
await liveSession.commitMutation(
|
|
158
169
|
{
|
|
159
170
|
type: "replace-with-keyframes",
|
|
160
171
|
animationId: existingPositionTween.id,
|
|
161
172
|
targetSelector: selector,
|
|
162
|
-
position:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
: tweenStart,
|
|
166
|
-
duration: tweenDur,
|
|
167
|
-
keyframes: merged,
|
|
173
|
+
position: roundTo3(recStart),
|
|
174
|
+
duration: roundTo3(duration),
|
|
175
|
+
keyframes,
|
|
168
176
|
},
|
|
169
|
-
{ label: "Gesture recording (
|
|
177
|
+
{ label: "Gesture recording (replace set)", softReload: true },
|
|
170
178
|
);
|
|
171
179
|
} else {
|
|
180
|
+
const tweenStart = existingPositionTween.resolvedStart ?? 0;
|
|
181
|
+
const tweenDur = existingPositionTween.duration ?? duration;
|
|
182
|
+
const tweenEnd = tweenStart + tweenDur;
|
|
183
|
+
const recEnd = recStart + duration;
|
|
184
|
+
|
|
185
|
+
// Only merge if the recording overlaps the existing tween's time range.
|
|
186
|
+
// No overlap → fall through to add-with-keyframes (creates a separate tween).
|
|
187
|
+
const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
|
|
188
|
+
|
|
189
|
+
if (overlaps) {
|
|
190
|
+
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
|
|
191
|
+
const rangeStartPct =
|
|
192
|
+
tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
|
|
193
|
+
const rangeEndPct =
|
|
194
|
+
tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
|
|
195
|
+
|
|
196
|
+
const preserved = existingKfs
|
|
197
|
+
.filter(
|
|
198
|
+
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
|
|
199
|
+
)
|
|
200
|
+
.map((kf) => ({
|
|
201
|
+
percentage: kf.percentage,
|
|
202
|
+
properties: kf.properties,
|
|
203
|
+
...(kf.ease ? { ease: kf.ease } : {}),
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
const mapped = keyframes.map((kf) => ({
|
|
207
|
+
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
|
|
208
|
+
properties: kf.properties,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
|
|
212
|
+
|
|
213
|
+
await liveSession.commitMutation(
|
|
214
|
+
{
|
|
215
|
+
type: "replace-with-keyframes",
|
|
216
|
+
animationId: existingPositionTween.id,
|
|
217
|
+
targetSelector: selector,
|
|
218
|
+
position:
|
|
219
|
+
typeof existingPositionTween.position === "number"
|
|
220
|
+
? existingPositionTween.position
|
|
221
|
+
: tweenStart,
|
|
222
|
+
duration: tweenDur,
|
|
223
|
+
keyframes: merged,
|
|
224
|
+
},
|
|
225
|
+
{ label: "Gesture recording (merge)", softReload: true },
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
// Emit one tween per property group so a mixed-prop gesture (e.g.
|
|
229
|
+
// x/y + opacity) doesn't collapse into an untagged legacy mixed
|
|
230
|
+
// tween that the position-only drag intercept can't edit.
|
|
231
|
+
for (const groupKfs of partitionKeyframesByGroup(keyframes)) {
|
|
232
|
+
await liveSession.commitMutation(
|
|
233
|
+
{
|
|
234
|
+
type: "add-with-keyframes",
|
|
235
|
+
targetSelector: selector,
|
|
236
|
+
position: roundTo3(recStart),
|
|
237
|
+
duration: roundTo3(duration),
|
|
238
|
+
keyframes: groupKfs,
|
|
239
|
+
},
|
|
240
|
+
{ label: "Gesture recording (new range)", softReload: true },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
// No existing tween — same per-group split as the new-range branch above.
|
|
247
|
+
for (const groupKfs of partitionKeyframesByGroup(keyframes)) {
|
|
172
248
|
await liveSession.commitMutation(
|
|
173
249
|
{
|
|
174
250
|
type: "add-with-keyframes",
|
|
175
251
|
targetSelector: selector,
|
|
176
252
|
position: roundTo3(recStart),
|
|
177
253
|
duration: roundTo3(duration),
|
|
178
|
-
keyframes,
|
|
254
|
+
keyframes: groupKfs,
|
|
179
255
|
},
|
|
180
|
-
{ label: "Gesture recording
|
|
256
|
+
{ label: "Gesture recording", softReload: true },
|
|
181
257
|
);
|
|
182
258
|
}
|
|
183
|
-
} else {
|
|
184
|
-
await liveSession.commitMutation(
|
|
185
|
-
{
|
|
186
|
-
type: "add-with-keyframes",
|
|
187
|
-
targetSelector: selector,
|
|
188
|
-
position: roundTo3(recStart),
|
|
189
|
-
duration: roundTo3(duration),
|
|
190
|
-
keyframes,
|
|
191
|
-
},
|
|
192
|
-
{ label: "Gesture recording", softReload: true },
|
|
193
|
-
);
|
|
194
259
|
}
|
|
195
260
|
}
|
|
196
261
|
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
|
|
@@ -206,6 +271,9 @@ export function useGestureCommit({
|
|
|
206
271
|
}, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
|
|
207
272
|
|
|
208
273
|
const handleToggleRecording = useCallback(() => {
|
|
274
|
+
editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
|
|
275
|
+
id: domEditSessionRef.current.domEditSelection?.id,
|
|
276
|
+
});
|
|
209
277
|
if (gestureStateRef.current === "recording") {
|
|
210
278
|
void stopAndCommitRecording();
|
|
211
279
|
return;
|