@hyperframes/studio 0.7.89 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-ulQEdWKo.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-CddKwfQ1.js +0 -428
@@ -0,0 +1,161 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
8
+ import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
9
+ import { defaultTimelineTheme } from "./timelineTheme";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ const roots: Root[] = [];
14
+ afterEach(() => {
15
+ for (const root of roots.splice(0)) act(() => root.unmount());
16
+ document.body.innerHTML = "";
17
+ usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
18
+ });
19
+
20
+ const captured: TimelineElement = {
21
+ id: "child",
22
+ key: "parent::child",
23
+ tag: "div",
24
+ start: 1,
25
+ duration: 2,
26
+ track: 3,
27
+ };
28
+
29
+ describe("resolveTimelineContextElement", () => {
30
+ it("returns the current expanded model instead of the captured snapshot", () => {
31
+ const current = { ...captured, start: 4, track: 7 };
32
+
33
+ expect(
34
+ resolveTimelineContextElement({
35
+ capturedElement: captured,
36
+ targetSessionEpoch: 2,
37
+ sessionEpoch: 2,
38
+ selectedElementId: "parent::child",
39
+ elements: [current],
40
+ }),
41
+ ).toBe(current);
42
+ });
43
+
44
+ it("resolves synthetic expanded children that are absent from raw store elements", () => {
45
+ expect(
46
+ resolveTimelineContextElement({
47
+ capturedElement: captured,
48
+ targetSessionEpoch: 2,
49
+ sessionEpoch: 2,
50
+ selectedElementId: "parent::child",
51
+ elements: [captured],
52
+ }),
53
+ ).toBe(captured);
54
+ });
55
+
56
+ it("rejects stale sessions, changed selection, and removed elements", () => {
57
+ const input = {
58
+ capturedElement: captured,
59
+ targetSessionEpoch: 2,
60
+ sessionEpoch: 2,
61
+ selectedElementId: "parent::child",
62
+ elements: [captured],
63
+ };
64
+
65
+ expect(resolveTimelineContextElement({ ...input, sessionEpoch: 3 })).toBeNull();
66
+ expect(resolveTimelineContextElement({ ...input, selectedElementId: "other" })).toBeNull();
67
+ expect(resolveTimelineContextElement({ ...input, elements: [] })).toBeNull();
68
+ });
69
+ });
70
+
71
+ function renderKeyframeOverlay(options: {
72
+ capturedElement: TimelineElement;
73
+ currentElement: TimelineElement;
74
+ setKfContextMenu?: ReturnType<typeof vi.fn>;
75
+ onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
76
+ }) {
77
+ const container = document.createElement("div");
78
+ document.body.appendChild(container);
79
+ const root = createRoot(container);
80
+ roots.push(root);
81
+ const elements = [options.currentElement];
82
+ const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
83
+ const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
84
+ const menu: KeyframeDiamondContextMenuState = {
85
+ x: 10,
86
+ y: 10,
87
+ sessionEpoch: 2,
88
+ element: options.capturedElement,
89
+ elementId: options.capturedElement.key ?? options.capturedElement.id,
90
+ percentage: 50,
91
+ animationId: "child-position",
92
+ };
93
+
94
+ act(() => {
95
+ usePlayerStore.setState({
96
+ selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
97
+ timelineSessionEpoch: 2,
98
+ });
99
+ root.render(
100
+ createElement(TimelineOverlays, {
101
+ elements,
102
+ elementsRef: { current: elements },
103
+ theme: defaultTimelineTheme,
104
+ showShortcutHint: false,
105
+ showPopover: false,
106
+ rangeSelection: null,
107
+ setShowPopover: vi.fn(),
108
+ setRangeSelection: vi.fn(),
109
+ kfContextMenu: menu,
110
+ setKfContextMenu,
111
+ onDeleteKeyframe: vi.fn(),
112
+ onDeleteAllKeyframes,
113
+ onMoveKeyframeToPlayhead: vi.fn(),
114
+ clipContextMenu: null,
115
+ setClipContextMenu: vi.fn(),
116
+ currentTime: 0,
117
+ onSplitElement: vi.fn(),
118
+ pinZoomBeforeEdit: vi.fn(),
119
+ onDeleteElement: vi.fn(),
120
+ gapContextMenu: null,
121
+ onDismissGapContextMenu: vi.fn(),
122
+ onCloseTrackGap: vi.fn(),
123
+ onCloseAllTrackGaps: vi.fn(),
124
+ onHoverGapAction: vi.fn(),
125
+ }),
126
+ );
127
+ });
128
+ return { setKfContextMenu, onDeleteAllKeyframes };
129
+ }
130
+
131
+ describe("TimelineOverlays context lifecycle", () => {
132
+ it("dismisses a keyframe menu when its selected target becomes stale", () => {
133
+ const setKfContextMenu = vi.fn();
134
+ renderKeyframeOverlay({
135
+ capturedElement: captured,
136
+ currentElement: captured,
137
+ setKfContextMenu,
138
+ });
139
+
140
+ act(() => usePlayerStore.setState({ selectedElementId: "other" }));
141
+
142
+ expect(setKfContextMenu).toHaveBeenCalledExactlyOnceWith(null);
143
+ });
144
+
145
+ it("dispatches a menu action with the current model element", () => {
146
+ const current = { ...captured, start: 4, track: 7 };
147
+ const onDeleteAllKeyframes = vi.fn();
148
+ renderKeyframeOverlay({
149
+ capturedElement: captured,
150
+ currentElement: current,
151
+ onDeleteAllKeyframes,
152
+ });
153
+ const button = Array.from(document.body.querySelectorAll("button")).find(
154
+ (candidate) => candidate.textContent === "Delete All Keyframes",
155
+ );
156
+
157
+ act(() => button?.click());
158
+
159
+ expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
160
+ });
161
+ });
@@ -1,4 +1,6 @@
1
+ import { useEffect, type MutableRefObject } from "react";
1
2
  import type { TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore } from "../store/playerStore";
2
4
  import type { TimelineTheme } from "./timelineTheme";
3
5
  import type { TimelineRangeSelection } from "./timelineEditing";
4
6
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -11,10 +13,11 @@ import { ClipContextMenu } from "./ClipContextMenu";
11
13
  import { TrackGapContextMenu } from "./TrackGapContextMenu";
12
14
  import { TimelineShortcutHint } from "./TimelineShortcutHint";
13
15
 
14
- interface ClipContextMenuState {
16
+ export interface ClipContextMenuState {
15
17
  x: number;
16
18
  y: number;
17
19
  element: TimelineElement;
20
+ sessionEpoch: number;
18
21
  }
19
22
 
20
23
  /** Resolved model for the empty-lane-space (track gap) context menu. */
@@ -28,6 +31,8 @@ interface TrackGapContextMenuState {
28
31
  }
29
32
 
30
33
  interface TimelineOverlaysProps {
34
+ elements: readonly TimelineElement[];
35
+ elementsRef: MutableRefObject<readonly TimelineElement[]>;
31
36
  theme: TimelineTheme;
32
37
  showShortcutHint: boolean;
33
38
  showPopover: boolean;
@@ -52,10 +57,49 @@ interface TimelineOverlaysProps {
52
57
  onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
53
58
  }
54
59
 
60
+ interface TimelineContextTargetInput {
61
+ capturedElement: TimelineElement;
62
+ targetSessionEpoch: number | undefined;
63
+ sessionEpoch: number;
64
+ selectedElementId: string | null;
65
+ elements: readonly TimelineElement[];
66
+ }
67
+
68
+ /** The captured project session and current selection jointly own a context target. */
69
+ export function resolveTimelineContextElement({
70
+ capturedElement,
71
+ targetSessionEpoch,
72
+ sessionEpoch,
73
+ selectedElementId,
74
+ elements,
75
+ }: TimelineContextTargetInput): TimelineElement | null {
76
+ const identity = capturedElement.key ?? capturedElement.id;
77
+ if (targetSessionEpoch !== sessionEpoch) return null;
78
+ if (selectedElementId !== identity) return null;
79
+ return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
80
+ }
81
+
82
+ function readTimelineContextElement(
83
+ capturedElement: TimelineElement,
84
+ targetSessionEpoch: number | undefined,
85
+ elements: readonly TimelineElement[],
86
+ ): TimelineElement | null {
87
+ const state = usePlayerStore.getState();
88
+ return resolveTimelineContextElement({
89
+ capturedElement,
90
+ targetSessionEpoch,
91
+ sessionEpoch: state.timelineSessionEpoch,
92
+ selectedElementId: state.selectedElementId,
93
+ elements,
94
+ });
95
+ }
96
+
55
97
  // The timeline's floating overlays, rendered as siblings above the scroll area:
56
98
  // the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
57
99
  // and the clip context menu.
58
100
  export function TimelineOverlays({
101
+ elements,
102
+ elementsRef,
59
103
  theme,
60
104
  showShortcutHint,
61
105
  showPopover,
@@ -79,6 +123,39 @@ export function TimelineOverlays({
79
123
  onCloseAllTrackGaps,
80
124
  onHoverGapAction,
81
125
  }: TimelineOverlaysProps) {
126
+ const selectedElementId = usePlayerStore((state) => state.selectedElementId);
127
+ const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
128
+ const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch;
129
+ const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch;
130
+ const keyframeElement = kfContextMenu
131
+ ? resolveTimelineContextElement({
132
+ capturedElement: kfContextMenu.element,
133
+ targetSessionEpoch: kfTargetSessionEpoch,
134
+ sessionEpoch,
135
+ selectedElementId,
136
+ elements,
137
+ })
138
+ : null;
139
+ const clipElement = clipContextMenu
140
+ ? resolveTimelineContextElement({
141
+ capturedElement: clipContextMenu.element,
142
+ targetSessionEpoch: clipTargetSessionEpoch,
143
+ sessionEpoch,
144
+ selectedElementId,
145
+ elements,
146
+ })
147
+ : null;
148
+ const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) =>
149
+ readTimelineContextElement(element, targetSessionEpoch, elementsRef.current);
150
+
151
+ useEffect(() => {
152
+ if (kfContextMenu && !keyframeElement) setKfContextMenu(null);
153
+ }, [keyframeElement, kfContextMenu, setKfContextMenu]);
154
+
155
+ useEffect(() => {
156
+ if (clipContextMenu && !clipElement) setClipContextMenu(null);
157
+ }, [clipContextMenu, clipElement, setClipContextMenu]);
158
+
82
159
  return (
83
160
  <>
84
161
  {showShortcutHint && !showPopover && !rangeSelection && (
@@ -98,29 +175,45 @@ export function TimelineOverlays({
98
175
  />
99
176
  )}
100
177
 
101
- {kfContextMenu && (
178
+ {kfContextMenu && keyframeElement && (
102
179
  <KeyframeDiamondContextMenu
103
- state={kfContextMenu}
180
+ state={{ ...kfContextMenu, element: keyframeElement }}
104
181
  onClose={() => setKfContextMenu(null)}
105
- onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
106
- onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
182
+ onDelete={(...args) => {
183
+ if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
184
+ onDeleteKeyframe?.(...args);
185
+ }}
186
+ onDeleteAll={(_element, animationId) => {
187
+ const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
188
+ if (element) onDeleteAllKeyframes?.(element, animationId);
189
+ }}
107
190
  onMoveToPlayhead={
108
- onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
191
+ onMoveKeyframeToPlayhead
192
+ ? (_element, ...args) => {
193
+ const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
194
+ if (element) onMoveKeyframeToPlayhead(element, ...args);
195
+ }
196
+ : undefined
109
197
  }
110
198
  />
111
199
  )}
112
200
 
113
- {clipContextMenu && (
201
+ {clipContextMenu && clipElement && (
114
202
  <ClipContextMenu
115
203
  x={clipContextMenu.x}
116
204
  y={clipContextMenu.y}
117
- element={clipContextMenu.element}
205
+ element={clipElement}
118
206
  currentTime={currentTime}
119
207
  onClose={() => setClipContextMenu(null)}
120
- onSplit={(el, time) => onSplitElement?.(el, time)}
121
- onDelete={(el) => {
208
+ onSplit={(_element, time) => {
209
+ const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
210
+ if (element) onSplitElement?.(element, time);
211
+ }}
212
+ onDelete={() => {
213
+ const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
214
+ if (!element) return;
122
215
  pinZoomBeforeEdit();
123
- onDeleteElement?.(el);
216
+ onDeleteElement?.(element);
124
217
  }}
125
218
  />
126
219
  )}
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
73
73
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
74
74
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
75
75
  onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
76
- onDeleteAllKeyframes?: (element: TimelineElement) => void;
76
+ onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
77
77
  onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
78
78
  /** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
79
79
  * is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
@@ -21,6 +21,7 @@ describe("timeline clip drag gesture lifecycle", () => {
21
21
  track: 0,
22
22
  };
23
23
  const drag: DraggedClipState = {
24
+ pointerId: 0,
24
25
  element,
25
26
  originClientX: 0,
26
27
  originClientY: 0,
@@ -49,23 +50,36 @@ describe("timeline clip drag gesture lifecycle", () => {
49
50
  }));
50
51
  const onMoveElement = vi.fn();
51
52
  const stopAutoScroll = vi.fn();
53
+ const cancelGestureRef = { current: () => false };
52
54
  const dispose = mountTimelineClipDragGestureLifecycle({
55
+ lifecycleRef: {
56
+ current: {
57
+ kind: "drag",
58
+ phase: "active",
59
+ pointerId: null,
60
+ sessionEpoch: 0,
61
+ },
62
+ },
63
+ sessionEpochRef: { current: 0 },
64
+ cancelGestureRef,
65
+ scrollRef: { current: null },
53
66
  draggedClipRef,
54
67
  resizingClipRef,
55
68
  blockedClipRef: { current: null },
56
69
  groupResizeRef: { current: null },
57
70
  suppressClickRef: { current: false },
71
+ gestureSelectedKeysRef: { current: new Set() },
58
72
  elementsRef: { current: [element] },
59
73
  trackOrderRef: { current: [0] },
60
- setDraggedClip,
61
- setResizingClip: () => {},
74
+ setDraggedClipState: setDraggedClip,
75
+ setResizingClipState: () => {},
62
76
  setShowPopover: () => {},
63
77
  setRangeSelectionRef: { current: null },
64
78
  applyResizePointerRef: { current: () => {} },
65
79
  syncClipDragAutoScrollRef: { current: () => {} },
66
80
  stopClipDragAutoScrollRef: { current: stopAutoScroll },
67
81
  updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
68
- restoreGroupResizeMembers: () => {},
82
+ publishDraggedClip: setDraggedClip,
69
83
  updateElement: vi.fn(),
70
84
  onMoveElementRef: { current: onMoveElement },
71
85
  onMoveElementsRef: { current: undefined },
@@ -93,5 +107,6 @@ describe("timeline clip drag gesture lifecycle", () => {
93
107
  expect(stopAutoScroll).toHaveBeenCalledTimes(1);
94
108
 
95
109
  dispose();
110
+ expect(cancelGestureRef.current()).toBe(false);
96
111
  });
97
112
  });