@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
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Resolves what a property lane's header row shows at the current playhead:
3
+ * which animation owns the lane right now, where the playhead sits inside it,
4
+ * the sampled values, and the add/remove keyframe target. Pure state, no JSX, so
5
+ * the header component only renders what this returns.
6
+ */
7
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
+ import {
9
+ clipToTweenPercentage,
10
+ getKeyframeNavigationState,
11
+ } from "../../components/editor/KeyframeNavigation";
12
+ import {
13
+ absoluteToPercentageForAnimation,
14
+ isTimeWithinTween,
15
+ resolveTweenDuration,
16
+ resolveTweenStart,
17
+ } from "../../utils/globalTimeCompiler";
18
+ import type { TimelinePropertyGroupKeyframeToggle } from "./timelineCallbacks";
19
+ import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
20
+ import { groupLabel, valuesAt, type LaneValues } from "./trackHeaderLaneValues";
21
+
22
+ export type TimelinePropertyLane = ReturnType<typeof getTimelinePropertyLanes>[number];
23
+ export type KeyframeNavigationState = ReturnType<
24
+ typeof getKeyframeNavigationState<TimelinePropertyLane["keyframes"][number]>
25
+ >;
26
+
27
+ function findNearestLaneKeyframe(lane: TimelinePropertyLane, clipPercentage: number) {
28
+ return lane.keyframes.reduce<(typeof lane.keyframes)[number] | null>(
29
+ (nearest, keyframe) =>
30
+ !nearest ||
31
+ Math.abs(keyframe.percentage - clipPercentage) < Math.abs(nearest.percentage - clipPercentage)
32
+ ? keyframe
33
+ : nearest,
34
+ null,
35
+ );
36
+ }
37
+
38
+ function findAnimationAtTime(animations: TimelinePropertyLane["animations"], currentTime: number) {
39
+ return animations.find((candidate) => {
40
+ const start = resolveTweenStart(candidate);
41
+ return start !== null && isTimeWithinTween(currentTime, start, resolveTweenDuration(candidate));
42
+ });
43
+ }
44
+
45
+ function resolveLaneAnimation(
46
+ lane: TimelinePropertyLane,
47
+ navigation: KeyframeNavigationState,
48
+ nearestKeyframe: TimelinePropertyLane["keyframes"][number] | null,
49
+ animationAtPlayhead: GsapAnimation | undefined,
50
+ ) {
51
+ const animationId = navigation.currentKeyframe?.animationId ?? nearestKeyframe?.animationId;
52
+ return animationAtPlayhead ?? lane.animations.find((candidate) => candidate.id === animationId);
53
+ }
54
+
55
+ function resolveLaneTweenPercentage(
56
+ navigation: KeyframeNavigationState,
57
+ animation: GsapAnimation | undefined,
58
+ animationKeyframes: TimelinePropertyLane["keyframes"],
59
+ currentTime: number,
60
+ clipPercentage: number,
61
+ ) {
62
+ return (
63
+ navigation.currentKeyframe?.tweenPercentage ??
64
+ (animation ? absoluteToPercentageForAnimation(currentTime, animation) : null) ??
65
+ clipToTweenPercentage(animationKeyframes, clipPercentage)
66
+ );
67
+ }
68
+
69
+ function createLaneToggleTarget(
70
+ animation: GsapAnimation | undefined,
71
+ lane: TimelinePropertyLane,
72
+ tweenPercentage: number,
73
+ values: LaneValues,
74
+ navigation: KeyframeNavigationState,
75
+ ): TimelinePropertyGroupKeyframeToggle | null {
76
+ return animation
77
+ ? {
78
+ animationId: animation.id,
79
+ propertyGroup: lane.group,
80
+ tweenPercentage,
81
+ properties: values,
82
+ remove: navigation.currentKeyframe !== null,
83
+ }
84
+ : null;
85
+ }
86
+
87
+ export interface LaneHeaderState {
88
+ navigation: KeyframeNavigationState;
89
+ values: LaneValues;
90
+ label: string;
91
+ toggleTarget: TimelinePropertyGroupKeyframeToggle | null;
92
+ }
93
+
94
+ export function resolveLaneHeaderState(
95
+ lane: TimelinePropertyLane,
96
+ currentTime: number,
97
+ clipPercentage: number,
98
+ ): LaneHeaderState {
99
+ const navigation = getKeyframeNavigationState(lane.keyframes, clipPercentage);
100
+ const nearestKeyframe = findNearestLaneKeyframe(lane, clipPercentage);
101
+ const animationAtPlayhead = findAnimationAtTime(lane.animations, currentTime);
102
+ const animation = resolveLaneAnimation(lane, navigation, nearestKeyframe, animationAtPlayhead);
103
+ const animationKeyframes = lane.keyframes.filter(
104
+ (keyframe) => keyframe.animationId === animation?.id,
105
+ );
106
+ const tweenPercentage = resolveLaneTweenPercentage(
107
+ navigation,
108
+ animation,
109
+ animationKeyframes,
110
+ currentTime,
111
+ clipPercentage,
112
+ );
113
+ const values = animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
114
+
115
+ return {
116
+ navigation,
117
+ values,
118
+ label: groupLabel(lane.group, values),
119
+ toggleTarget: createLaneToggleTarget(animation, lane, tweenPercentage, values, navigation),
120
+ };
121
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Sampling and formatting for the property-lane readouts in the track header:
3
+ * what value a property holds at a given tween percentage, and how that value
4
+ * reads to a human. Kept apart from the header's JSX so a formatting change and
5
+ * a layout change never touch the same file.
6
+ */
7
+ import gsap from "gsap";
8
+ import {
9
+ classifyPropertyGroup,
10
+ type GsapAnimation,
11
+ type PropertyGroupName,
12
+ } from "@hyperframes/core/gsap-parser";
13
+
14
+ export type LaneValues = Record<string, number | string>;
15
+
16
+ function roundValue(value: number): string {
17
+ return String(Math.round(value * 100) / 100);
18
+ }
19
+
20
+ /** GSAP applies a keyframe's `ease` to the segment ARRIVING at it, so the curve
21
+ * between two keyframes is named by the later one (with the tween-level
22
+ * `easeEach`/`ease` as the fallback). Sampling linearly would report a value the
23
+ * element never has at that time, and stamp that wrong value onto a keyframe
24
+ * added from the track header. Unknown ease names parse to undefined -> linear. */
25
+ function easedProgress(progress: number, animation: GsapAnimation, ease?: string): number {
26
+ const resolved = ease ?? animation.keyframes?.easeEach ?? animation.ease;
27
+ if (!resolved || resolved === "none") return progress;
28
+ return gsap.parseEase(resolved)?.(progress) ?? progress;
29
+ }
30
+
31
+ interface PropertyStop {
32
+ percentage: number;
33
+ value: number | string;
34
+ ease?: string;
35
+ }
36
+
37
+ function propertyStops(animation: GsapAnimation, property: string): PropertyStop[] {
38
+ return (animation.keyframes?.keyframes ?? [])
39
+ .filter((keyframe) => property in keyframe.properties)
40
+ .map((keyframe) => ({
41
+ percentage: keyframe.percentage,
42
+ value: keyframe.properties[property],
43
+ ease: keyframe.ease,
44
+ }));
45
+ }
46
+
47
+ /** A pair only interpolates when both ends are numeric and actually span time;
48
+ * string values (colors, keywords) and zero-width pairs hold the earlier value.
49
+ * Returns the numeric ends so the caller needs no cast to use them. */
50
+ function interpolableEnds(
51
+ before: PropertyStop,
52
+ after: PropertyStop,
53
+ ): { from: number; to: number } | null {
54
+ if (typeof before.value !== "number" || typeof after.value !== "number") return null;
55
+ if (before.percentage === after.percentage) return null;
56
+ return { from: before.value, to: after.value };
57
+ }
58
+
59
+ function propertyValueAt(
60
+ animation: GsapAnimation,
61
+ property: string,
62
+ tweenPercentage: number,
63
+ ): number | string | undefined {
64
+ const stops = propertyStops(animation, property);
65
+ const before = stops.filter((stop) => stop.percentage <= tweenPercentage).at(-1);
66
+ const after = stops.find((stop) => stop.percentage >= tweenPercentage);
67
+ if (!before) return after?.value;
68
+ if (!after) return before.value;
69
+ const ends = interpolableEnds(before, after);
70
+ if (!ends) return before.value;
71
+ const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
72
+ return ends.from + (ends.to - ends.from) * easedProgress(progress, animation, after.ease);
73
+ }
74
+
75
+ /** Every property of `group` this animation touches, sampled at `tweenPercentage`. */
76
+ export function valuesAt(
77
+ animation: GsapAnimation,
78
+ group: PropertyGroupName,
79
+ tweenPercentage: number,
80
+ ): LaneValues {
81
+ const propertyNames = new Set<string>();
82
+ for (const keyframe of animation.keyframes?.keyframes ?? []) {
83
+ for (const property of Object.keys(keyframe.properties)) {
84
+ if (classifyPropertyGroup(property) === group) propertyNames.add(property);
85
+ }
86
+ }
87
+ const values: LaneValues = {};
88
+ for (const property of propertyNames) {
89
+ const value = propertyValueAt(animation, property, tweenPercentage);
90
+ if (value !== undefined) values[property] = value;
91
+ }
92
+ return values;
93
+ }
94
+
95
+ export function groupLabel(group: PropertyGroupName, properties: LaneValues): string {
96
+ if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
97
+ return "Opacity";
98
+ }
99
+ if (group !== "other") return `${group[0]?.toUpperCase() ?? ""}${group.slice(1)}`;
100
+ const property = Object.keys(properties)[0];
101
+ return property ? `${property[0]?.toUpperCase() ?? ""}${property.slice(1)}` : "Other";
102
+ }
103
+
104
+ function defaultValueReadout(values: LaneValues): string {
105
+ return Object.values(values)
106
+ .map((value) => (typeof value === "number" ? roundValue(value) : value))
107
+ .join(", ");
108
+ }
109
+
110
+ function positionValueReadout(values: LaneValues): string | null {
111
+ const x = values.x;
112
+ const y = values.y;
113
+ return typeof x === "number" && typeof y === "number"
114
+ ? `${roundValue(x)}, ${roundValue(y)}`
115
+ : null;
116
+ }
117
+
118
+ function rotationValueReadout(values: LaneValues): string | null {
119
+ return typeof values.rotation === "number" ? `${roundValue(values.rotation)}°` : null;
120
+ }
121
+
122
+ function visualValueReadout(values: LaneValues): string | null {
123
+ const opacity = values.opacity ?? values.autoAlpha;
124
+ return typeof opacity === "number"
125
+ ? `${roundValue(Math.abs(opacity) <= 1 ? opacity * 100 : opacity)}%`
126
+ : null;
127
+ }
128
+
129
+ const GROUP_VALUE_READOUTS: Partial<
130
+ Record<PropertyGroupName, (values: LaneValues) => string | null>
131
+ > = {
132
+ position: positionValueReadout,
133
+ rotation: rotationValueReadout,
134
+ visual: visualValueReadout,
135
+ };
136
+
137
+ export function valueReadout(group: PropertyGroupName, values: LaneValues): string {
138
+ return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values);
139
+ }
@@ -0,0 +1,75 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
9
+
10
+ const studioShell = vi.hoisted(() => ({ projectId: "project-a" }));
11
+ vi.mock("../../contexts/StudioContext", () => ({
12
+ useStudioShellContextOptional: () => studioShell,
13
+ }));
14
+
15
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ studioShell.projectId = "project-a";
20
+ usePlayerStore.getState().reset();
21
+ });
22
+
23
+ const animations = new Map<string, GsapAnimation[]>([
24
+ [
25
+ "clip-1",
26
+ [
27
+ {
28
+ id: "position-tween",
29
+ targetSelector: "#clip-1",
30
+ method: "to",
31
+ position: 0,
32
+ duration: 1,
33
+ properties: { x: 100 },
34
+ propertyGroup: "position",
35
+ },
36
+ ],
37
+ ],
38
+ ]);
39
+
40
+ function AutoExpandHarness({ value }: { value: Map<string, GsapAnimation[]> }) {
41
+ useAutoExpandKeyframedClips(value);
42
+ return null;
43
+ }
44
+
45
+ describe("useAutoExpandKeyframedClips", () => {
46
+ it("preserves manual collapse within a project and expands again in a different project", () => {
47
+ const host = document.createElement("div");
48
+ document.body.append(host);
49
+ const root = createRoot(host);
50
+ const render = (projectId: string, value = new Map(animations)) => {
51
+ studioShell.projectId = projectId;
52
+ act(() => root.render(<AutoExpandHarness value={value} />));
53
+ };
54
+
55
+ const projectAAnimations = new Map(animations);
56
+ render("project-a", projectAAnimations);
57
+ expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
58
+
59
+ act(() => usePlayerStore.getState().toggleClipExpanded("clip-1"));
60
+ expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
61
+
62
+ const refreshedProjectAAnimations = new Map(animations);
63
+ render("project-a", refreshedProjectAAnimations);
64
+ expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
65
+
66
+ render("project-b", refreshedProjectAAnimations);
67
+ expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
68
+
69
+ render("project-b", new Map());
70
+ render("project-b");
71
+ expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
72
+
73
+ act(() => root.unmount());
74
+ });
75
+ });
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { usePlayerStore } from "../store/playerStore";
4
+ import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
5
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
6
+ import { animationContributesLane } from "./TimelinePropertyLanes";
7
+
8
+ /**
9
+ * Keyframed clips start expanded (AE/Figma default). Auto-expands each clip the
10
+ * first time it contributes a lane — real keyframes OR a synthesizable flat tween
11
+ * — tracked per-clip so a later user collapse sticks and never bounces back open
12
+ * (and clips added later still auto-expand).
13
+ */
14
+ /**
15
+ * Prunes clips that left the source, then returns the ones that newly contribute
16
+ * a lane. The prune matters because the set is otherwise append-only: a clip
17
+ * deleted and reinserted under the same id (undo, paste) would be remembered as
18
+ * already-expanded and never auto-expand again.
19
+ */
20
+ function freshLaneClips(gsapAnimations: Map<string, GsapAnimation[]>, clips: Set<string>) {
21
+ for (const key of clips) {
22
+ if (!gsapAnimations.has(key)) clips.delete(key);
23
+ }
24
+ const fresh: string[] = [];
25
+ for (const [key, animations] of gsapAnimations) {
26
+ if (clips.has(key)) continue;
27
+ if (animations.some(animationContributesLane)) fresh.push(key);
28
+ }
29
+ return fresh;
30
+ }
31
+
32
+ export function useAutoExpandKeyframedClips(gsapAnimations: Map<string, GsapAnimation[]>): void {
33
+ const expandClips = usePlayerStore((s) => s.expandClips);
34
+ const projectId = useStudioShellContextOptional()?.projectId ?? null;
35
+ const seen = useRef({ projectId, source: gsapAnimations, clips: new Set<string>() });
36
+ useEffect(() => {
37
+ if (!STUDIO_KEYFRAMES_ENABLED) return;
38
+ if (seen.current.projectId !== projectId) {
39
+ const sourceChanged = seen.current.source !== gsapAnimations;
40
+ seen.current = { projectId, source: gsapAnimations, clips: new Set() };
41
+ if (!sourceChanged) return;
42
+ } else {
43
+ seen.current.source = gsapAnimations;
44
+ }
45
+ const fresh = freshLaneClips(gsapAnimations, seen.current.clips);
46
+ if (fresh.length === 0) return;
47
+ for (const key of fresh) seen.current.clips.add(key);
48
+ expandClips(fresh);
49
+ }, [gsapAnimations, expandClips, projectId]);
50
+ }
@@ -48,6 +48,7 @@ interface UseTimelineClipDragInput {
48
48
  ppsRef: React.RefObject<number>;
49
49
  durationRef: React.RefObject<number>;
50
50
  trackOrderRef: React.RefObject<number[]>;
51
+ rowHeightsRef?: React.RefObject<readonly number[]>;
51
52
  onMoveElement?: (
52
53
  element: TimelineElement,
53
54
  updates: Pick<TimelineElement, "start" | "track">,
@@ -85,6 +86,7 @@ export function useTimelineClipDrag({
85
86
  ppsRef,
86
87
  durationRef,
87
88
  trackOrderRef,
89
+ rowHeightsRef,
88
90
  onMoveElement,
89
91
  onMoveElements,
90
92
  onResizeElement,
@@ -241,13 +243,14 @@ export function useTimelineClipDrag({
241
243
  pps: ppsRef.current,
242
244
  duration: durationRef.current,
243
245
  trackOrder: trackOrderRef.current,
246
+ rowHeights: rowHeightsRef?.current,
244
247
  elements: elementsRef.current,
245
248
  selectedKeys: usePlayerStore.getState().selectedElementIds,
246
249
  buildSnapTargets,
247
250
  audioTracks: dragAudioTracksRef.current,
248
251
  });
249
252
  },
250
- [scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets],
253
+ [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets],
251
254
  );
252
255
 
253
256
  // Recompute the trim preview for a pointer x. Shared by the pointermove resize
@@ -21,6 +21,7 @@ interface UseTimelineGeometryInput {
21
21
  isDragging: RefObject<boolean>;
22
22
  scrollRef: RefObject<HTMLDivElement | null>;
23
23
  lastScrollLeftRef: RefObject<number>;
24
+ contentOrigin: number;
24
25
  }
25
26
 
26
27
  // Derive the timeline's horizontal geometry from the viewport, zoom, and any live
@@ -40,10 +41,11 @@ export function useTimelineGeometry({
40
41
  isDragging,
41
42
  scrollRef,
42
43
  lastScrollLeftRef,
44
+ contentOrigin,
43
45
  }: UseTimelineGeometryInput) {
44
46
  // Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
45
47
  // comps show a 60s ruler with usable empty space (see getTimelineFitPps).
46
- const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration);
48
+ const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
47
49
  const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
48
50
  ppsRef.current = pps;
49
51
  const trackContentWidth = Math.max(0, effectiveDuration * pps);
@@ -70,6 +72,7 @@ export function useTimelineGeometry({
70
72
  const displayContentWidth = getTimelineDisplayContentWidth({
71
73
  trackContentWidth,
72
74
  viewportWidth,
75
+ contentOrigin,
73
76
  pps,
74
77
  dragGhostEndPx,
75
78
  resizeGhostEndPx,
@@ -0,0 +1,108 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
9
+ import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
14
+ vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
15
+
16
+ const ELEMENT: TimelineElement = {
17
+ id: "clip-1",
18
+ label: "Hero card",
19
+ tag: "div",
20
+ start: 1,
21
+ duration: 2,
22
+ track: 0,
23
+ };
24
+
25
+ const TARGET: TimelineKeyframeTarget = {
26
+ percentage: 50,
27
+ tweenPercentage: 50,
28
+ propertyGroup: "position",
29
+ animationId: "position-tween",
30
+ };
31
+
32
+ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
33
+ percentage: 100,
34
+ tweenPercentage: 100,
35
+ propertyGroup: "position",
36
+ animationId: "position-tween",
37
+ };
38
+
39
+ afterEach(() => {
40
+ document.body.innerHTML = "";
41
+ trackStudioSegmentEaseEdit.mockClear();
42
+ usePlayerStore.setState({ focusedEaseSegment: null });
43
+ });
44
+
45
+ describe("useTimelineKeyframeHandlers", () => {
46
+ it("tracks opening the segment ease editor when a timeline segment is selected", () => {
47
+ let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
48
+
49
+ function Harness() {
50
+ ({ onSelectSegment } = useTimelineKeyframeHandlers({
51
+ expandedElements: [ELEMENT],
52
+ keyframeCache: new Map(),
53
+ setSelectedElementId: vi.fn(),
54
+ setKfContextMenu: vi.fn(),
55
+ toggleSelectedKeyframe: vi.fn(),
56
+ }));
57
+ return null;
58
+ }
59
+
60
+ const root = mountReactHarness(<Harness />);
61
+ act(() => onSelectSegment?.(ELEMENT.id, TARGET));
62
+
63
+ expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
64
+ expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
65
+ act(() => root.unmount());
66
+ });
67
+
68
+ it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
69
+ const onSeek = vi.fn();
70
+ const onSelectElement = vi.fn();
71
+ const setSelectedElementId = vi.fn();
72
+ let onClickKeyframe:
73
+ | ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
74
+ | undefined;
75
+ let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
76
+
77
+ function Harness() {
78
+ ({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
79
+ expandedElements: [ELEMENT],
80
+ keyframeCache: new Map(),
81
+ onSelectElement,
82
+ onSeek,
83
+ setSelectedElementId,
84
+ setKfContextMenu: vi.fn(),
85
+ toggleSelectedKeyframe: vi.fn(),
86
+ }));
87
+ return null;
88
+ }
89
+
90
+ const root = mountReactHarness(<Harness />);
91
+
92
+ // Selecting a segment must NOT move the playhead.
93
+ act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
94
+ expect(onSeek).not.toHaveBeenCalled();
95
+ expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
96
+ animationId: "position-tween",
97
+ tweenPercentage: 100,
98
+ elementId: ELEMENT.id,
99
+ });
100
+ expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
101
+ expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
102
+
103
+ // Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
104
+ act(() => onClickKeyframe?.(ELEMENT, TARGET));
105
+ expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
106
+ act(() => root.unmount());
107
+ });
108
+ });
@@ -1,7 +1,12 @@
1
1
  import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
2
+ import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
2
3
  import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
3
4
  import { usePlayerStore } from "../store/playerStore";
4
5
  import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
6
+ import {
7
+ timelineKeyframeSelectionKey,
8
+ type TimelineKeyframeTarget,
9
+ } from "./timelineKeyframeIdentity";
5
10
 
6
11
  interface UseTimelineKeyframeHandlersInput {
7
12
  expandedElements: TimelineElement[];
@@ -23,42 +28,71 @@ export function useTimelineKeyframeHandlers({
23
28
  toggleSelectedKeyframe,
24
29
  }: UseTimelineKeyframeHandlersInput) {
25
30
  const onClickKeyframe = useCallback(
26
- (el: TimelineElement, pct: number) => {
31
+ (el: TimelineElement, target: TimelineKeyframeTarget, options?: { seek?: boolean }) => {
27
32
  usePlayerStore.getState().clearSelectedKeyframes();
28
33
  const elKey = el.key ?? el.id;
29
34
  setSelectedElementId(elKey);
30
35
  onSelectElement?.(el);
31
- toggleSelectedKeyframe(`${elKey}:${pct}`);
32
- onSeek?.(el.start + (pct / 100) * el.duration);
36
+ toggleSelectedKeyframe(timelineKeyframeSelectionKey(elKey, target));
37
+ // Clicking a diamond seeks the playhead to it; selecting a segment to edit
38
+ // its ease (options.seek === false) must NOT move the playhead.
39
+ if (options?.seek !== false) {
40
+ onSeek?.(el.start + (target.percentage / 100) * el.duration);
41
+ }
33
42
  const kfData = keyframeCache.get(elKey);
34
- const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5);
35
- usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
43
+ const kf = kfData?.keyframes.find(
44
+ (item) => Math.abs(item.percentage - target.percentage) < 0.5,
45
+ );
46
+ usePlayerStore
47
+ .getState()
48
+ .setActiveKeyframePct(target.tweenPercentage ?? kf?.tweenPercentage ?? null);
36
49
  },
37
50
  [keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
38
51
  );
39
52
 
40
53
  const onShiftClickKeyframe = useCallback(
41
- (elId: string, pct: number) => {
42
- toggleSelectedKeyframe(`${elId}:${pct}`);
54
+ (elId: string, target: TimelineKeyframeTarget) => {
55
+ toggleSelectedKeyframe(timelineKeyframeSelectionKey(elId, target));
43
56
  },
44
57
  [toggleSelectedKeyframe],
45
58
  );
46
59
 
47
- const onContextMenuKeyframe = useCallback(
48
- (e: ReactMouseEvent, elId: string, pct: number) => {
60
+ const onSelectSegment = useCallback(
61
+ (elId: string, target: TimelineKeyframeTarget) => {
49
62
  const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
50
- if (el) {
51
- setSelectedElementId(elId);
52
- onSelectElement?.(el);
63
+ if (!el) return;
64
+ onClickKeyframe(el, target, { seek: false });
65
+ if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
66
+ usePlayerStore.getState().setFocusedEaseSegment({
67
+ animationId: target.animationId,
68
+ tweenPercentage: target.tweenPercentage,
69
+ elementId: elId,
70
+ });
71
+ trackStudioSegmentEaseEdit({ action: "open" });
53
72
  }
73
+ },
74
+ [expandedElements, onClickKeyframe],
75
+ );
76
+
77
+ const onContextMenuKeyframe = useCallback(
78
+ (e: ReactMouseEvent, elId: string, target: TimelineKeyframeTarget) => {
79
+ const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
80
+ if (!el) return;
81
+ setSelectedElementId(elId);
82
+ onSelectElement?.(el);
54
83
  const kfData = keyframeCache.get(elId);
55
- const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
84
+ const kf = kfData?.keyframes.find(
85
+ (item) => Math.abs(item.percentage - target.percentage) < 0.2,
86
+ );
56
87
  setKfContextMenu({
57
88
  x: e.clientX + 4,
58
89
  y: e.clientY + 2,
59
90
  elementId: elId,
60
- percentage: pct,
61
- tweenPercentage: kf?.tweenPercentage,
91
+ percentage: target.percentage,
92
+ tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
93
+ propertyGroup: target.propertyGroup,
94
+ animationId: target.animationId,
95
+ element: el,
62
96
  currentEase: kf?.ease ?? kfData?.ease,
63
97
  });
64
98
  },
@@ -67,6 +101,7 @@ export function useTimelineKeyframeHandlers({
67
101
 
68
102
  return {
69
103
  onClickKeyframe,
104
+ onSelectSegment,
70
105
  onShiftClickKeyframe,
71
106
  onContextMenuKeyframe,
72
107
  };