@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
|
@@ -1,54 +1,30 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
2
|
-
import type { GsapAnimation, GsapKeyframesData
|
|
3
|
-
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser";
|
|
4
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
4
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
6
5
|
import {
|
|
7
6
|
clearKeyframeCacheForElement,
|
|
8
|
-
|
|
7
|
+
pruneKeyframeCacheToFiles,
|
|
8
|
+
writeGsapAnimationsForElement,
|
|
9
9
|
} from "./gsapKeyframeCacheHelpers";
|
|
10
|
-
import { toAbsoluteTime } from "./gsapShared";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
selector: string,
|
|
29
|
-
doc: Document | null | undefined,
|
|
30
|
-
): string[] {
|
|
31
|
-
const bareId = selector.match(/^#([\w-]+)$/);
|
|
32
|
-
if (bareId) return [bareId[1]];
|
|
33
|
-
if (!doc) {
|
|
34
|
-
const lead = extractIdFromSelector(selector);
|
|
35
|
-
return lead ? [lead] : [];
|
|
36
|
-
}
|
|
37
|
-
const ids = new Set<string>();
|
|
38
|
-
for (const part of selector.split(",")) {
|
|
39
|
-
const sel = part.trim();
|
|
40
|
-
if (!sel) continue;
|
|
41
|
-
try {
|
|
42
|
-
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
|
43
|
-
if (el.id) ids.add(el.id);
|
|
44
|
-
}
|
|
45
|
-
} catch {
|
|
46
|
-
const lead = extractIdFromSelector(sel);
|
|
47
|
-
if (lead) ids.add(lead);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return Array.from(ids);
|
|
51
|
-
}
|
|
10
|
+
import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
11
|
+
import {
|
|
12
|
+
deduplicateKeyframes,
|
|
13
|
+
isStaticPositionHold,
|
|
14
|
+
synthesizeFlatTweenKeyframes,
|
|
15
|
+
} from "./gsapTweenSynth";
|
|
16
|
+
import {
|
|
17
|
+
fetchParsedAnimations,
|
|
18
|
+
populateKeyframeCacheFromAst,
|
|
19
|
+
resolveClipTimingBasis,
|
|
20
|
+
} from "./keyframeCacheAstLoad";
|
|
21
|
+
|
|
22
|
+
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
|
23
|
+
export {
|
|
24
|
+
fetchParsedAnimations,
|
|
25
|
+
resolveClipTimingBasis,
|
|
26
|
+
resolveSelectorElementIds,
|
|
27
|
+
} from "./keyframeCacheAstLoad";
|
|
52
28
|
|
|
53
29
|
/** The selected element's identity for matching tweens to it. */
|
|
54
30
|
export interface GsapElementTarget {
|
|
@@ -98,59 +74,6 @@ export function getAnimationsForElement(
|
|
|
98
74
|
);
|
|
99
75
|
}
|
|
100
76
|
|
|
101
|
-
export async function fetchParsedAnimations(
|
|
102
|
-
projectId: string,
|
|
103
|
-
sourceFile: string,
|
|
104
|
-
): Promise<ParsedGsap | null> {
|
|
105
|
-
try {
|
|
106
|
-
const res = await fetch(
|
|
107
|
-
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
|
108
|
-
// Always re-read the freshly-parsed source; no per-call timestamp (which
|
|
109
|
-
// would defeat caching forever and is a deterministic-render no-no).
|
|
110
|
-
{ cache: "no-store" },
|
|
111
|
-
);
|
|
112
|
-
if (!res.ok) return null;
|
|
113
|
-
const parsed = (await res.json()) as ParsedGsap;
|
|
114
|
-
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
|
|
115
|
-
// hold an element's first keyframe before its tween). They must not surface as
|
|
116
|
-
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
|
|
117
|
-
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
|
|
118
|
-
} catch {
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
|
125
|
-
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
|
126
|
-
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
|
127
|
-
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
|
128
|
-
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
|
129
|
-
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
|
130
|
-
*/
|
|
131
|
-
export function resolveClipTimingBasis(
|
|
132
|
-
elementId: string,
|
|
133
|
-
sourceFile: string,
|
|
134
|
-
elements: ReadonlyArray<{
|
|
135
|
-
domId?: string;
|
|
136
|
-
key?: string;
|
|
137
|
-
id: string;
|
|
138
|
-
start: number;
|
|
139
|
-
duration: number;
|
|
140
|
-
}>,
|
|
141
|
-
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
|
142
|
-
): { elStart: number; elDuration: number } {
|
|
143
|
-
const direct = elements.find(
|
|
144
|
-
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
145
|
-
);
|
|
146
|
-
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
|
147
|
-
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
|
148
|
-
const host = hostId
|
|
149
|
-
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
|
150
|
-
: undefined;
|
|
151
|
-
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
77
|
export function useGsapAnimationsForElement(
|
|
155
78
|
projectId: string | null,
|
|
156
79
|
sourceFile: string,
|
|
@@ -328,6 +251,17 @@ export function useGsapAnimationsForElement(
|
|
|
328
251
|
// fallow-ignore-next-line complexity
|
|
329
252
|
useEffect(() => {
|
|
330
253
|
if (!elementId) return;
|
|
254
|
+
// Same admission rule as the keyframe cache below (hold skip included) and
|
|
255
|
+
// no property-group filter: the two stores must agree, or a hold draws an
|
|
256
|
+
// expanded property lane with no collapsed diamond behind it and an
|
|
257
|
+
// ungrouped tween draws diamonds with no lane source.
|
|
258
|
+
const sourceAnimations = animations.filter(
|
|
259
|
+
(animation) =>
|
|
260
|
+
!isStaticPositionHold(animation) &&
|
|
261
|
+
(animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
|
|
262
|
+
);
|
|
263
|
+
if (sourceAnimations.length > 0)
|
|
264
|
+
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
|
331
265
|
|
|
332
266
|
// Resolve the element's time range from the player store so we can
|
|
333
267
|
// convert tween-relative keyframe percentages to clip-relative ones.
|
|
@@ -340,24 +274,17 @@ export function useGsapAnimationsForElement(
|
|
|
340
274
|
);
|
|
341
275
|
|
|
342
276
|
const allKeyframes: Array<
|
|
343
|
-
GsapKeyframesData["keyframes"][0] & {
|
|
277
|
+
GsapKeyframesData["keyframes"][0] & {
|
|
278
|
+
tweenPercentage?: number;
|
|
279
|
+
propertyGroup?: string;
|
|
280
|
+
animationId?: string;
|
|
281
|
+
}
|
|
344
282
|
> = [];
|
|
345
283
|
let format: GsapKeyframesData["format"] = "percentage";
|
|
346
284
|
let ease: string | undefined;
|
|
347
285
|
let easeEach: string | undefined;
|
|
348
286
|
for (const anim of animations) {
|
|
349
|
-
|
|
350
|
-
// keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)`
|
|
351
|
-
// and the `tl.to({ duration: 0, immediateRender: true })` hold that
|
|
352
|
-
// remove-all-keyframes collapses to (which is otherwise shown as a stray
|
|
353
|
-
// 0% keyframe).
|
|
354
|
-
if (
|
|
355
|
-
!anim.keyframes &&
|
|
356
|
-
Object.keys(anim.properties).length > 0 &&
|
|
357
|
-
Object.keys(anim.properties).every((k) => k === "x" || k === "y") &&
|
|
358
|
-
(anim.method === "set" || (anim.duration ?? 0) === 0)
|
|
359
|
-
)
|
|
360
|
-
continue;
|
|
287
|
+
if (isStaticPositionHold(anim)) continue;
|
|
361
288
|
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
362
289
|
if (!kf) continue;
|
|
363
290
|
// Convert tween-relative percentages to clip-relative so diamonds
|
|
@@ -367,17 +294,13 @@ export function useGsapAnimationsForElement(
|
|
|
367
294
|
const tweenDur = anim.duration ?? elDuration;
|
|
368
295
|
for (const k of kf.keyframes) {
|
|
369
296
|
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
|
|
370
|
-
|
|
371
|
-
// on the beat dot, which is rendered at the true beat time.
|
|
372
|
-
const clipPct =
|
|
373
|
-
elDuration > 0
|
|
374
|
-
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
|
375
|
-
: k.percentage;
|
|
297
|
+
const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
|
|
376
298
|
allKeyframes.push({
|
|
377
299
|
...k,
|
|
378
300
|
percentage: clipPct,
|
|
379
301
|
tweenPercentage: k.percentage,
|
|
380
302
|
propertyGroup: anim.propertyGroup,
|
|
303
|
+
animationId: anim.id,
|
|
381
304
|
});
|
|
382
305
|
}
|
|
383
306
|
format = kf.format;
|
|
@@ -424,6 +347,7 @@ export function useGsapCacheVersion() {
|
|
|
424
347
|
* elements. Called from the Timeline component so diamonds show without
|
|
425
348
|
* requiring a selection.
|
|
426
349
|
*/
|
|
350
|
+
|
|
427
351
|
export function usePopulateKeyframeCacheForFile(
|
|
428
352
|
projectId: string | null,
|
|
429
353
|
sourceFile: string,
|
|
@@ -431,6 +355,16 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
431
355
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
432
356
|
): void {
|
|
433
357
|
const elementCount = usePlayerStore((s) => s.elements.length);
|
|
358
|
+
// Every sub-composition file the timeline shows rows for. The cache is loaded
|
|
359
|
+
// for all of them up front, so keyframe lanes are populated on open instead of
|
|
360
|
+
// only once a clip from that file is selected (which is what switches
|
|
361
|
+
// `sourceFile`). Only files reachable from the store's elements are covered;
|
|
362
|
+
// a composition nested inside another still loads on first selection.
|
|
363
|
+
const compositionSrcKey = usePlayerStore((s) =>
|
|
364
|
+
Array.from(new Set(s.elements.map((el) => el.compositionSrc).filter((src) => !!src)))
|
|
365
|
+
.sort()
|
|
366
|
+
.join("|"),
|
|
367
|
+
);
|
|
434
368
|
// Re-run when sub-comp DOM children appear (they supply the host bounds the
|
|
435
369
|
// clip-relative keyframe percentages are computed against; without this the
|
|
436
370
|
// cache is computed once before they exist and the percentages stay wrong).
|
|
@@ -443,72 +377,24 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
443
377
|
const astFetchDoneRef = useRef("");
|
|
444
378
|
|
|
445
379
|
useEffect(() => {
|
|
446
|
-
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
|
|
380
|
+
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
|
|
447
381
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
448
382
|
lastFetchKeyRef.current = fetchKey;
|
|
449
383
|
runtimeScanDoneRef.current = "";
|
|
450
384
|
astFetchDoneRef.current = "";
|
|
451
385
|
if (!projectId) return;
|
|
452
386
|
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// Position-only static holds are not keyframed animations — skip them so
|
|
465
|
-
// they don't draw a timeline diamond. Covers both a `tl.set(...)` and the
|
|
466
|
-
// `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes
|
|
467
|
-
// collapses a keyframed tween to.
|
|
468
|
-
if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) {
|
|
469
|
-
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
|
470
|
-
if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) {
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
475
|
-
if (!kfData) continue;
|
|
476
|
-
const tweenPos =
|
|
477
|
-
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
478
|
-
const tweenDur = anim.duration ?? 1;
|
|
479
|
-
// Attribute the tween to every element it animates (handles class /
|
|
480
|
-
// group / descendant selectors, not just `#id`).
|
|
481
|
-
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
|
482
|
-
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
|
483
|
-
const clipKeyframes = kfData.keyframes.map((kf) => {
|
|
484
|
-
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
|
485
|
-
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
|
486
|
-
// beat-snapped keyframe centers exactly on the beat dot and the two
|
|
487
|
-
// caches agree on a keyframe's percentage.
|
|
488
|
-
const clipPct =
|
|
489
|
-
elDuration > 0
|
|
490
|
-
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
|
491
|
-
: kf.percentage;
|
|
492
|
-
return {
|
|
493
|
-
...kf,
|
|
494
|
-
percentage: clipPct,
|
|
495
|
-
tweenPercentage: kf.percentage,
|
|
496
|
-
propertyGroup: anim.propertyGroup,
|
|
497
|
-
};
|
|
498
|
-
});
|
|
499
|
-
const existing = mergedByElement.get(id);
|
|
500
|
-
if (existing) {
|
|
501
|
-
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
|
502
|
-
} else {
|
|
503
|
-
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
for (const [id, kfData] of mergedByElement) {
|
|
508
|
-
setKeyframeCache(`${sf}#${id}`, kfData);
|
|
509
|
-
setKeyframeCache(id, kfData);
|
|
510
|
-
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
|
|
511
|
-
}
|
|
387
|
+
// The active file first: it owns the selection, and each file clears only
|
|
388
|
+
// its own cache entries, so the order just decides who writes the bare
|
|
389
|
+
// `id` alias last.
|
|
390
|
+
const files = Array.from(
|
|
391
|
+
new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]),
|
|
392
|
+
);
|
|
393
|
+
const doc = iframeRef?.current?.contentDocument;
|
|
394
|
+
// Everything the previous scan cached for a file this one no longer covers
|
|
395
|
+
// (the composition just switched away from) has no owner left to clear it.
|
|
396
|
+
pruneKeyframeCacheToFiles(files);
|
|
397
|
+
Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => {
|
|
512
398
|
astFetchDoneRef.current = fetchKey;
|
|
513
399
|
});
|
|
514
400
|
// elementCount is in the deps because new timeline elements (e.g. after a
|
|
@@ -517,7 +403,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
517
403
|
// iframeRef is read for DOM selector resolution but intentionally not a dep
|
|
518
404
|
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
|
|
519
405
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
520
|
-
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
|
|
406
|
+
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey, compositionSrcKey]);
|
|
521
407
|
|
|
522
408
|
// Separate effect for runtime keyframe discovery — polls until the iframe
|
|
523
409
|
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
|
|
9
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
|
+
|
|
11
|
+
function HookHost() {
|
|
12
|
+
usePopulateKeyframeCacheForFile("demo", "index.html", 1);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let root: Root | null = null;
|
|
17
|
+
let container: HTMLElement | null = null;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
usePlayerStore.setState({
|
|
21
|
+
elements: [
|
|
22
|
+
{
|
|
23
|
+
id: "lab",
|
|
24
|
+
tag: "div",
|
|
25
|
+
start: 0,
|
|
26
|
+
duration: 12,
|
|
27
|
+
track: 1,
|
|
28
|
+
compositionSrc: "compositions/keyframe-lab.html",
|
|
29
|
+
},
|
|
30
|
+
] as never,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
if (root) {
|
|
36
|
+
act(() => root?.unmount());
|
|
37
|
+
root = null;
|
|
38
|
+
}
|
|
39
|
+
container?.remove();
|
|
40
|
+
vi.unstubAllGlobals();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("usePopulateKeyframeCacheForFile", () => {
|
|
44
|
+
it("loads every sub-composition file the timeline shows, not just the active one", async () => {
|
|
45
|
+
// Keyframe lanes must be populated when the project opens. Fetching only the
|
|
46
|
+
// active file left them empty until a clip from the sub-composition was
|
|
47
|
+
// selected (which is the only thing that switched `sourceFile`).
|
|
48
|
+
const urls: string[] = [];
|
|
49
|
+
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
|
50
|
+
urls.push(String(input));
|
|
51
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve({ animations: [] }) });
|
|
52
|
+
});
|
|
53
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
54
|
+
|
|
55
|
+
act(() => {
|
|
56
|
+
container = document.createElement("div");
|
|
57
|
+
document.body.appendChild(container);
|
|
58
|
+
root = createRoot(container);
|
|
59
|
+
root.render(<HookHost />);
|
|
60
|
+
});
|
|
61
|
+
await act(async () => {
|
|
62
|
+
await Promise.resolve();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(urls.some((u) => u.endsWith("/index.html"))).toBe(true);
|
|
66
|
+
expect(urls.some((u) => u.includes("keyframe-lab.html"))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
7
|
+
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
|
8
|
+
import { makeSelection } from "./domSelectionTestHarness";
|
|
9
|
+
import { useInspectorState, type InspectorState } from "./useStudioContextValue";
|
|
10
|
+
|
|
11
|
+
interface HarnessProps {
|
|
12
|
+
rightPanelTab: string;
|
|
13
|
+
rightInspectorPanes: RightInspectorPanes;
|
|
14
|
+
rightCollapsed: boolean;
|
|
15
|
+
isPlaying: boolean;
|
|
16
|
+
isGestureRecording: boolean;
|
|
17
|
+
domEditSelection: DomEditSelection | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderInspectorState(props: HarnessProps): InspectorState {
|
|
21
|
+
let state: InspectorState | null = null;
|
|
22
|
+
|
|
23
|
+
function Harness() {
|
|
24
|
+
state = useInspectorState(
|
|
25
|
+
props.rightPanelTab,
|
|
26
|
+
props.rightInspectorPanes,
|
|
27
|
+
props.rightCollapsed,
|
|
28
|
+
props.isPlaying,
|
|
29
|
+
props.domEditSelection,
|
|
30
|
+
props.isGestureRecording,
|
|
31
|
+
);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
renderToStaticMarkup(React.createElement(Harness));
|
|
36
|
+
if (!state) throw new Error("Expected inspector state");
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function selectedProps(
|
|
41
|
+
overrides: Partial<HarnessProps> = {},
|
|
42
|
+
): HarnessProps & { domEditSelection: DomEditSelection } {
|
|
43
|
+
const element = document.createElement("div");
|
|
44
|
+
return {
|
|
45
|
+
rightPanelTab: "renders",
|
|
46
|
+
rightInspectorPanes: { layers: false, design: false },
|
|
47
|
+
rightCollapsed: true,
|
|
48
|
+
isPlaying: false,
|
|
49
|
+
isGestureRecording: false,
|
|
50
|
+
domEditSelection: makeSelection("Selected", element),
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("useInspectorState", () => {
|
|
56
|
+
it("shows the motion path for pure selection with the inspector collapsed", () => {
|
|
57
|
+
expect(renderInspectorState(selectedProps()).shouldShowMotionPath).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("hides the motion path without a selection", () => {
|
|
61
|
+
expect(
|
|
62
|
+
renderInspectorState({ ...selectedProps(), domEditSelection: null }).shouldShowMotionPath,
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("hides the motion path during playback", () => {
|
|
67
|
+
expect(renderInspectorState(selectedProps({ isPlaying: true })).shouldShowMotionPath).toBe(
|
|
68
|
+
false,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("hides the motion path during gesture recording", () => {
|
|
73
|
+
expect(
|
|
74
|
+
renderInspectorState(selectedProps({ isGestureRecording: true })).shouldShowMotionPath,
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps selected DOM bounds coupled to the inspector or variables panel", () => {
|
|
79
|
+
expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false);
|
|
80
|
+
expect(
|
|
81
|
+
renderInspectorState(
|
|
82
|
+
selectedProps({
|
|
83
|
+
rightPanelTab: "design",
|
|
84
|
+
rightInspectorPanes: { layers: false, design: true },
|
|
85
|
+
}),
|
|
86
|
+
).shouldShowSelectedDomBounds,
|
|
87
|
+
).toBe(true);
|
|
88
|
+
expect(
|
|
89
|
+
renderInspectorState(selectedProps({ rightPanelTab: "variables" }))
|
|
90
|
+
.shouldShowSelectedDomBounds,
|
|
91
|
+
).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
|
|
2
2
|
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
4
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
4
5
|
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
|
5
6
|
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
|
|
@@ -69,6 +70,7 @@ export interface InspectorState {
|
|
|
69
70
|
designPanelActive: boolean;
|
|
70
71
|
inspectorPanelActive: boolean;
|
|
71
72
|
inspectorButtonActive: boolean;
|
|
73
|
+
shouldShowMotionPath: boolean;
|
|
72
74
|
shouldShowSelectedDomBounds: boolean;
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -77,6 +79,7 @@ export function useInspectorState(
|
|
|
77
79
|
rightInspectorPanes: RightInspectorPanes,
|
|
78
80
|
rightCollapsed: boolean,
|
|
79
81
|
isPlaying: boolean,
|
|
82
|
+
domEditSelection: DomEditSelection | null,
|
|
80
83
|
isGestureRecording?: boolean,
|
|
81
84
|
): InspectorState {
|
|
82
85
|
// fallow-ignore-next-line complexity
|
|
@@ -93,8 +96,12 @@ export function useInspectorState(
|
|
|
93
96
|
inspectorPanelActive,
|
|
94
97
|
inspectorButtonActive:
|
|
95
98
|
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
|
96
|
-
//
|
|
97
|
-
//
|
|
99
|
+
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
|
|
100
|
+
// handles ARE the arc-drag affordance, so gating them on an open Inspector
|
|
101
|
+
// would make keyframe path editing reachable only from a side panel.
|
|
102
|
+
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
|
|
103
|
+
// Keep the selection box drawn even when the Inspector is collapsed —
|
|
104
|
+
// closing the panel shouldn't visually deselect the element.
|
|
98
105
|
// The Variables tab also works against the canvas selection (bind card),
|
|
99
106
|
// so the selection outline stays visible there too.
|
|
100
107
|
shouldShowSelectedDomBounds:
|
|
@@ -102,7 +109,14 @@ export function useInspectorState(
|
|
|
102
109
|
!isPlaying &&
|
|
103
110
|
!isGestureRecording,
|
|
104
111
|
};
|
|
105
|
-
}, [
|
|
112
|
+
}, [
|
|
113
|
+
rightPanelTab,
|
|
114
|
+
rightInspectorPanes,
|
|
115
|
+
rightCollapsed,
|
|
116
|
+
isPlaying,
|
|
117
|
+
isGestureRecording,
|
|
118
|
+
domEditSelection,
|
|
119
|
+
]);
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
// fallow-ignore-next-line complexity
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
|
|
4
|
+
interface StudioTestHookDeps {
|
|
5
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
6
|
+
buildDomSelectionFromTarget: (target: HTMLElement) => Promise<DomEditSelection | null>;
|
|
7
|
+
applyDomSelection: (
|
|
8
|
+
selection: DomEditSelection | null,
|
|
9
|
+
options?: { revealPanel?: boolean },
|
|
10
|
+
) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StudioTestApi {
|
|
14
|
+
selectByDomId: (id: string) => Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
__studioTest?: StudioTestApi;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dev-only headless-QA shortcut. Selecting an element normally requires a
|
|
25
|
+
* pixel-precise click inside the preview iframe, which automated verification
|
|
26
|
+
* can't reliably land. `window.__studioTest.selectByDomId(id)` resolves the
|
|
27
|
+
* DomEditSelection for a preview element by id and reveals the inspector —
|
|
28
|
+
* exactly what a click does — so a driver can open the property/ease panels and
|
|
29
|
+
* then focus a segment via `__playerStore.getState().setFocusedEaseSegment`.
|
|
30
|
+
* No-op in production builds.
|
|
31
|
+
*/
|
|
32
|
+
export function useStudioTestHooks({
|
|
33
|
+
previewIframeRef,
|
|
34
|
+
buildDomSelectionFromTarget,
|
|
35
|
+
applyDomSelection,
|
|
36
|
+
}: StudioTestHookDeps): void {
|
|
37
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let isDev = false;
|
|
40
|
+
try {
|
|
41
|
+
isDev = import.meta.env.DEV === true;
|
|
42
|
+
} catch {
|
|
43
|
+
isDev = false;
|
|
44
|
+
}
|
|
45
|
+
if (!isDev || typeof window === "undefined") return;
|
|
46
|
+
const api: StudioTestApi = {
|
|
47
|
+
selectByDomId: async (id: string): Promise<boolean> => {
|
|
48
|
+
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
|
|
49
|
+
if (!element) return false;
|
|
50
|
+
const selection = await buildDomSelectionFromTarget(element);
|
|
51
|
+
if (!selection) return false;
|
|
52
|
+
applyDomSelection(selection, { revealPanel: true });
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
window.__studioTest = api;
|
|
57
|
+
return () => {
|
|
58
|
+
// delete, not `= undefined`: an own key holding undefined keeps
|
|
59
|
+
// `"__studioTest" in window` true, which defeats feature detection.
|
|
60
|
+
delete window.__studioTest;
|
|
61
|
+
};
|
|
62
|
+
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
|
|
63
|
+
}
|