@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.
Files changed (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. 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, ParsedGsap } from "@hyperframes/core/gsap-parser";
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
- clearKeyframeCacheForFile,
7
+ pruneKeyframeCacheToFiles,
8
+ writeGsapAnimationsForElement,
9
9
  } from "./gsapKeyframeCacheHelpers";
10
- import { toAbsoluteTime } from "./gsapShared";
11
- import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
12
-
13
- function extractIdFromSelector(selector: string): string | null {
14
- const match = selector.match(/^#([\w-]+)/);
15
- return match ? match[1] : null;
16
- }
17
-
18
- /**
19
- * Resolve a tween's target selector to the ids of the element(s) it animates.
20
- * A bare `#id` resolves directly; anything else (a class like `.dot`, a group
21
- * `.a, .b`, or a descendant selector) is matched against the live preview DOM so
22
- * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
23
- * element they animate — not just one parsed from the string. Falls back to a
24
- * leading `#id` when there's no DOM (so the cache still populates pre-iframe).
25
- */
26
- // fallow-ignore-next-line complexity
27
- export function resolveSelectorElementIds(
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] & { tweenPercentage?: number; propertyGroup?: string }
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
- // A static position hold (only x/y, no real motion) is a `set`, not a
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
- // 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
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
- const sf = sourceFile;
454
- // fallow-ignore-next-line complexity
455
- fetchParsedAnimations(projectId, sf).then((parsed) => {
456
- if (!parsed) return;
457
- const { setKeyframeCache } = usePlayerStore.getState();
458
- clearKeyframeCacheForFile(sf);
459
- const { elements, domClipChildren } = usePlayerStore.getState();
460
- const doc = iframeRef?.current?.contentDocument;
461
- const mergedByElement = new Map<string, GsapKeyframesData>();
462
- for (const anim of parsed.animations) {
463
- if (anim.hasUnresolvedKeyframes) continue;
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
- // Keep the selection box + motion path drawn even when the Inspector is
97
- // collapsed closing the panel shouldn't visually deselect the element.
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
- }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
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
+ }