@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -3,23 +3,51 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import {
9
+ TimelineClipDiamonds,
10
+ TimelineDiamondLane,
11
+ type TimelineDiamondKeyframe,
12
+ } from "./TimelineClipDiamonds";
7
13
  import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
14
+ import { configureTimelineTestViewport } from "./timelineTestViewport";
15
+ import { readPendingTimelineKeyframeRetimes } from "./useTimelineKeyframeHandlers";
8
16
 
9
17
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
18
 
11
19
  afterEach(() => {
12
20
  document.body.innerHTML = "";
21
+ usePlayerStore.setState({ elements: [], timelineSessionEpoch: 0 });
13
22
  });
14
23
 
24
+ const RETIME_ELEMENT: TimelineElement = {
25
+ id: "clip-1",
26
+ label: "Clip",
27
+ tag: "div",
28
+ start: 0,
29
+ duration: 10,
30
+ track: 0,
31
+ };
32
+
15
33
  function pointerEvent(type: string, init: PointerEventInit): Event {
16
- if (typeof PointerEvent === "function") return new PointerEvent(type, init);
17
- return new MouseEvent(type, init);
34
+ const event =
35
+ typeof PointerEvent === "function" ? new PointerEvent(type, init) : new MouseEvent(type, init);
36
+ if (!("pointerId" in event)) {
37
+ Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 });
38
+ }
39
+ return event;
18
40
  }
19
41
 
20
- function renderDiamonds(onClickKeyframe = vi.fn()) {
42
+ function createTimelineHost() {
21
43
  const host = document.createElement("div");
44
+ host.setAttribute("data-timeline-scroll-viewport", "");
22
45
  document.body.append(host);
46
+ return host;
47
+ }
48
+
49
+ function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) {
50
+ const host = createTimelineHost();
23
51
  const root = createRoot(host);
24
52
  act(() => {
25
53
  root.render(
@@ -33,17 +61,130 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
33
61
  }}
34
62
  clipWidthPx={200}
35
63
  clipHeightPx={48}
36
- clipDuration={10}
37
64
  accentColor="#4ba3d2"
38
65
  isSelected
39
66
  currentPercentage={0}
40
67
  elementId="clip-1"
68
+ clipStart={10}
69
+ clipDuration={10}
41
70
  selectedKeyframes={new Set()}
42
71
  onClickKeyframe={onClickKeyframe}
72
+ onShiftClickKeyframe={onShiftClickKeyframe}
73
+ />,
74
+ );
75
+ });
76
+ return { host, root, onClickKeyframe, onShiftClickKeyframe };
77
+ }
78
+
79
+ it("pins authored keyframes outside the clip to an inspectable boundary marker", () => {
80
+ const host = createTimelineHost();
81
+ const root = createRoot(host);
82
+ act(() => {
83
+ root.render(
84
+ <TimelineDiamondLane
85
+ keyframesData={{
86
+ format: "percentage",
87
+ keyframes: [
88
+ { percentage: -40, properties: { x: 0 }, propertyGroup: "position" },
89
+ { percentage: -20, properties: { x: 25 }, propertyGroup: "position" },
90
+ { percentage: 5, properties: { x: 50 }, propertyGroup: "position" },
91
+ { percentage: 120, properties: { x: 100 }, propertyGroup: "position" },
92
+ ],
93
+ }}
94
+ clipWidthPx={200}
95
+ clipHeightPx={48}
96
+ accentColor="#4ba3d2"
97
+ isSelected
98
+ currentPercentage={5}
99
+ elementId="clip-1"
100
+ clipStart={10}
101
+ clipDuration={10}
102
+ selectedKeyframes={new Set()}
43
103
  />,
44
104
  );
45
105
  });
46
- return { host, root, onClickKeyframe };
106
+
107
+ const before = host.querySelectorAll<HTMLButtonElement>('[data-keyframe-outside-clip="before"]');
108
+ const after = host.querySelector<HTMLButtonElement>('[data-keyframe-outside-clip="after"]');
109
+ expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]);
110
+ expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)");
111
+ expect(after?.style.left).toBe("201px");
112
+ expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)");
113
+ expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4);
114
+
115
+ act(() => root.unmount());
116
+ });
117
+
118
+ function renderRetimeLane(
119
+ onMoveKeyframe = vi.fn().mockResolvedValue(true),
120
+ strict = false,
121
+ options: {
122
+ elementId?: string;
123
+ host?: HTMLDivElement;
124
+ keyframes?: TimelineDiamondKeyframe[];
125
+ } = {},
126
+ ) {
127
+ const elementId = options.elementId ?? RETIME_ELEMENT.id;
128
+ if (!options.host) {
129
+ usePlayerStore.setState({ elements: [{ ...RETIME_ELEMENT, id: elementId, key: elementId }] });
130
+ }
131
+ const host = options.host ?? createTimelineHost();
132
+ const keyframes = options.keyframes ?? [
133
+ {
134
+ percentage: 0,
135
+ tweenPercentage: 0,
136
+ propertyGroup: "position",
137
+ animationId: "anim-1",
138
+ properties: { x: 0 },
139
+ },
140
+ {
141
+ percentage: 50,
142
+ tweenPercentage: 50,
143
+ propertyGroup: "position",
144
+ animationId: "anim-1",
145
+ properties: { x: 100 },
146
+ },
147
+ {
148
+ percentage: 100,
149
+ tweenPercentage: 100,
150
+ propertyGroup: "position",
151
+ animationId: "anim-1",
152
+ properties: { x: 200 },
153
+ },
154
+ ];
155
+ const onClickKeyframe = vi.fn();
156
+ const mountLane = () => {
157
+ const laneHost = document.createElement("div");
158
+ host.append(laneHost);
159
+ const root = createRoot(laneHost);
160
+ const lane = (
161
+ <TimelineDiamondLane
162
+ keyframesData={{
163
+ format: "percentage",
164
+ keyframes,
165
+ }}
166
+ clipWidthPx={200}
167
+ clipHeightPx={48}
168
+ accentColor="#4ba3d2"
169
+ isSelected
170
+ currentPercentage={0}
171
+ elementId={elementId}
172
+ selectedKeyframes={new Set()}
173
+ onClickKeyframe={onClickKeyframe}
174
+ onMoveKeyframe={onMoveKeyframe}
175
+ groupAware
176
+ />
177
+ );
178
+ act(() => {
179
+ root.render(strict ? <React.StrictMode>{lane}</React.StrictMode> : lane);
180
+ });
181
+ const diamond = laneHost.querySelector<HTMLButtonElement>(
182
+ `button[title="${keyframes[1]?.percentage}%"]`,
183
+ );
184
+ expect(diamond).not.toBeNull();
185
+ return { diamond: diamond!, root };
186
+ };
187
+ return { ...mountLane(), host, mountLane, onClickKeyframe, onMoveKeyframe };
47
188
  }
48
189
 
49
190
  describe("TimelineClipDiamonds", () => {
@@ -161,6 +302,50 @@ describe("TimelineClipDiamonds", () => {
161
302
  act(() => root.unmount());
162
303
  });
163
304
 
305
+ it("gives keyframes time-based names and native keyboard selection semantics", () => {
306
+ const { host, root, onClickKeyframe } = renderDiamonds();
307
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
308
+ expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s");
309
+ expect(diamond.getAttribute("aria-pressed")).toBe("false");
310
+ act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 })));
311
+ expect(onClickKeyframe).toHaveBeenCalledWith(
312
+ "clip-1",
313
+ expect.objectContaining({ percentage: 50 }),
314
+ );
315
+ act(() => root.unmount());
316
+ });
317
+
318
+ it("uses Shift+Space's native click for additive keyframe selection", () => {
319
+ const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds();
320
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
321
+ act(() =>
322
+ diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })),
323
+ );
324
+ expect(onShiftClickKeyframe).toHaveBeenCalledWith(
325
+ "clip-1",
326
+ expect.objectContaining({ percentage: 50 }),
327
+ );
328
+ expect(onClickKeyframe).not.toHaveBeenCalled();
329
+ act(() => root.unmount());
330
+ });
331
+
332
+ it("publishes retime previews after StrictMode effect replay", () => {
333
+ const { diamond, host, root } = renderRetimeLane(undefined, true);
334
+ const initialLeft = diamond.style.left;
335
+ act(() => {
336
+ diamond.dispatchEvent(
337
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
338
+ );
339
+ window.dispatchEvent(
340
+ pointerEvent("pointermove", { bubbles: true, clientX: 120, pointerId: 7 }),
341
+ );
342
+ });
343
+ expect(host.querySelector<HTMLButtonElement>('button[title="50%"]')?.style.left).not.toBe(
344
+ initialLeft,
345
+ );
346
+ act(() => root.unmount());
347
+ });
348
+
164
349
  it("treats primary pointerup without drag as a keyframe click", () => {
165
350
  const { host, root, onClickKeyframe } = renderDiamonds();
166
351
  const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
@@ -246,8 +431,7 @@ describe("TimelineClipDiamonds", () => {
246
431
  it("treats a drag-armed press that resolves to a no-op move as a click", () => {
247
432
  const onClickKeyframe = vi.fn();
248
433
  const onMoveKeyframe = vi.fn();
249
- const host = document.createElement("div");
250
- document.body.append(host);
434
+ const host = createTimelineHost();
251
435
  const root = createRoot(host);
252
436
  act(() => {
253
437
  root.render(
@@ -314,8 +498,7 @@ describe("TimelineClipDiamonds", () => {
314
498
  it("reselects a retimed keyframe with its post-move tween percentage", () => {
315
499
  const onClickKeyframe = vi.fn();
316
500
  const onMoveKeyframe = vi.fn().mockResolvedValue(true);
317
- const host = document.createElement("div");
318
- document.body.append(host);
501
+ const host = createTimelineHost();
319
502
  const root = createRoot(host);
320
503
  act(() => {
321
504
  root.render(
@@ -474,8 +657,7 @@ describe("TimelineClipDiamonds", () => {
474
657
 
475
658
  it("composes a rapid second retime from the pending position", () => {
476
659
  const onMoveKeyframe = vi.fn().mockResolvedValue(true);
477
- const host = document.createElement("div");
478
- document.body.append(host);
660
+ const host = createTimelineHost();
479
661
  const root = createRoot(host);
480
662
  act(() => {
481
663
  root.render(
@@ -548,16 +730,120 @@ describe("TimelineClipDiamonds", () => {
548
730
  },
549
731
  85,
550
732
  );
733
+
734
+ act(() => {
735
+ usePlayerStore.setState({ timelineSessionEpoch: 1 });
736
+ diamond!.dispatchEvent(
737
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
738
+ );
739
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
740
+ });
741
+ expect(onMoveKeyframe).toHaveBeenNthCalledWith(
742
+ 3,
743
+ expect.objectContaining({ percentage: 50 }),
744
+ 60,
745
+ );
551
746
  act(() => root.unmount());
552
747
  });
553
748
 
749
+ it("preserves another element's pending retime when the active source is removed", () => {
750
+ const host = createTimelineHost();
751
+ const elementA = { ...RETIME_ELEMENT, id: "clip-a", key: "clip-a" };
752
+ const elementB = { ...RETIME_ELEMENT, id: "clip-b", key: "clip-b" };
753
+ usePlayerStore.setState({ elements: [elementA, elementB] });
754
+ const laneA = renderRetimeLane(vi.fn().mockResolvedValue(true), false, {
755
+ elementId: "clip-a",
756
+ host,
757
+ });
758
+ const onMoveB = vi.fn().mockResolvedValue(true);
759
+ const laneB = renderRetimeLane(onMoveB, false, { elementId: "clip-b", host });
760
+
761
+ act(() => {
762
+ laneB.diamond.dispatchEvent(
763
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
764
+ );
765
+ window.dispatchEvent(
766
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }),
767
+ );
768
+
769
+ laneA.diamond.dispatchEvent(
770
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
771
+ );
772
+ usePlayerStore.setState({ elements: [elementB] });
773
+
774
+ laneB.diamond.dispatchEvent(
775
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 11 }),
776
+ );
777
+ window.dispatchEvent(
778
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170, pointerId: 11 }),
779
+ );
780
+ });
781
+
782
+ expect(onMoveB).toHaveBeenNthCalledWith(2, expect.objectContaining({ percentage: 75 }), 85);
783
+ act(() => {
784
+ laneA.root.unmount();
785
+ laneB.root.unmount();
786
+ });
787
+ });
788
+
789
+ it("retires a pending retime once the cache exposes its destination identity", () => {
790
+ const first = renderRetimeLane();
791
+ act(() => {
792
+ first.diamond.dispatchEvent(
793
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
794
+ );
795
+ window.dispatchEvent(
796
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }),
797
+ );
798
+ });
799
+ expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(1);
800
+ act(() => first.root.unmount());
801
+
802
+ const destination = renderRetimeLane(vi.fn().mockResolvedValue(true), false, {
803
+ host: first.host,
804
+ keyframes: [
805
+ {
806
+ percentage: 0,
807
+ tweenPercentage: 0,
808
+ propertyGroup: "position",
809
+ animationId: "anim-1",
810
+ properties: { x: 0 },
811
+ },
812
+ {
813
+ percentage: 75,
814
+ tweenPercentage: 75,
815
+ propertyGroup: "position",
816
+ animationId: "anim-1",
817
+ properties: { x: 100 },
818
+ },
819
+ {
820
+ percentage: 100,
821
+ tweenPercentage: 100,
822
+ propertyGroup: "position",
823
+ animationId: "anim-1",
824
+ properties: { x: 200 },
825
+ },
826
+ ],
827
+ });
828
+ act(() => {
829
+ destination.diamond.dispatchEvent(
830
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 9 }),
831
+ );
832
+ });
833
+ expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(0);
834
+
835
+ act(() => {
836
+ window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 9 }));
837
+ destination.root.unmount();
838
+ });
839
+ });
840
+
554
841
  it.each([
555
842
  ["returns false", () => Promise.resolve(false)],
556
843
  ["rejects", () => Promise.reject(new Error("retime failed"))],
557
844
  ])("clears a failed pending retime when the callback %s", async (_label, settle) => {
558
845
  const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
559
- const host = document.createElement("div");
560
- document.body.append(host);
846
+ const host = createTimelineHost();
561
847
  const root = createRoot(host);
562
848
  act(() => {
563
849
  root.render(
@@ -678,17 +964,224 @@ describe("TimelineClipDiamonds", () => {
678
964
  act(() => root.unmount());
679
965
  });
680
966
 
967
+ it("commits once from the stable viewport after the source lane unmounts", () => {
968
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
969
+
970
+ act(() => {
971
+ diamond.dispatchEvent(
972
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
973
+ );
974
+ root.unmount();
975
+ window.dispatchEvent(
976
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
977
+ );
978
+ window.dispatchEvent(
979
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }),
980
+ );
981
+ });
982
+
983
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(
984
+ {
985
+ percentage: 50,
986
+ tweenPercentage: 50,
987
+ propertyGroup: "position",
988
+ animationId: "anim-1",
989
+ },
990
+ 60,
991
+ );
992
+ });
993
+
994
+ it("keeps the retime preview coherent when the source lane remounts", () => {
995
+ const { diamond, mountLane, onMoveKeyframe, root } = renderRetimeLane();
996
+
997
+ act(() => {
998
+ diamond.dispatchEvent(
999
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1000
+ );
1001
+ window.dispatchEvent(
1002
+ pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1003
+ );
1004
+ });
1005
+ const beforeUnmountLeft = Number.parseFloat(diamond.style.left);
1006
+
1007
+ act(() => {
1008
+ root.unmount();
1009
+ window.dispatchEvent(
1010
+ pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }),
1011
+ );
1012
+ });
1013
+ const { diamond: remountedDiamond, root: remountedRoot } = mountLane();
1014
+ const remountedLeft = Number.parseFloat(remountedDiamond.style.left);
1015
+ expect(remountedLeft).toBeGreaterThan(beforeUnmountLeft);
1016
+
1017
+ act(() => {
1018
+ window.dispatchEvent(
1019
+ pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }),
1020
+ );
1021
+ });
1022
+ expect(Number.parseFloat(remountedDiamond.style.left)).toBeGreaterThan(remountedLeft);
1023
+
1024
+ act(() => {
1025
+ window.dispatchEvent(
1026
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }),
1027
+ );
1028
+ });
1029
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 80);
1030
+ act(() => remountedRoot.unmount());
1031
+ });
1032
+
1033
+ it("includes horizontal viewport scrolling in the retime destination", () => {
1034
+ const { diamond, host, onMoveKeyframe, root } = renderRetimeLane();
1035
+
1036
+ act(() => {
1037
+ diamond.dispatchEvent(
1038
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1039
+ );
1040
+ host.scrollLeft = 20;
1041
+ window.dispatchEvent(
1042
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1043
+ );
1044
+ });
1045
+
1046
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 60);
1047
+ act(() => root.unmount());
1048
+ });
1049
+
1050
+ it("auto-scrolls horizontally without virtualizing the source row away", () => {
1051
+ let frame: FrameRequestCallback | null = null;
1052
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
1053
+ frame = callback;
1054
+ return 1;
1055
+ });
1056
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
1057
+ const { diamond, host, root } = renderRetimeLane();
1058
+ configureTimelineTestViewport(host, 10_000);
1059
+
1060
+ act(() => {
1061
+ diamond.dispatchEvent(
1062
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1063
+ );
1064
+ window.dispatchEvent(
1065
+ pointerEvent("pointermove", {
1066
+ bubbles: true,
1067
+ button: 0,
1068
+ clientX: 790,
1069
+ clientY: 239,
1070
+ pointerId: 7,
1071
+ }),
1072
+ );
1073
+ });
1074
+ expect(frame).not.toBeNull();
1075
+ act(() => frame?.(0));
1076
+ expect(host.scrollLeft).toBeGreaterThan(0);
1077
+ expect(host.scrollTop).toBe(0);
1078
+
1079
+ act(() => {
1080
+ window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 7 }));
1081
+ root.unmount();
1082
+ });
1083
+ });
1084
+
1085
+ it("ignores another pointer and lets the owning pointer finish", () => {
1086
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
1087
+
1088
+ act(() => {
1089
+ diamond.dispatchEvent(
1090
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1091
+ );
1092
+ window.dispatchEvent(
1093
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 8 }),
1094
+ );
1095
+ window.dispatchEvent(
1096
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1097
+ );
1098
+ });
1099
+
1100
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
1101
+ act(() => root.unmount());
1102
+ });
1103
+
1104
+ it("cancels without mutation on pointer cancel or Escape and allows the next retime", () => {
1105
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
1106
+
1107
+ act(() => {
1108
+ diamond.dispatchEvent(
1109
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1110
+ );
1111
+ window.dispatchEvent(
1112
+ pointerEvent("pointercancel", {
1113
+ bubbles: true,
1114
+ button: 0,
1115
+ clientX: 120,
1116
+ pointerId: 7,
1117
+ }),
1118
+ );
1119
+ window.dispatchEvent(
1120
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1121
+ );
1122
+
1123
+ diamond.dispatchEvent(
1124
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
1125
+ );
1126
+ window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
1127
+ window.dispatchEvent(
1128
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }),
1129
+ );
1130
+
1131
+ diamond.dispatchEvent(
1132
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }),
1133
+ );
1134
+ window.dispatchEvent(
1135
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }),
1136
+ );
1137
+ });
1138
+
1139
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
1140
+ act(() => root.unmount());
1141
+ });
1142
+
1143
+ it("cancels on project switch or source removal without poisoning the next gesture", () => {
1144
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
1145
+
1146
+ act(() => {
1147
+ diamond.dispatchEvent(
1148
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1149
+ );
1150
+ usePlayerStore.setState({ timelineSessionEpoch: 1 });
1151
+ window.dispatchEvent(
1152
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1153
+ );
1154
+
1155
+ diamond.dispatchEvent(
1156
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
1157
+ );
1158
+ usePlayerStore.setState({ elements: [] });
1159
+ window.dispatchEvent(
1160
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }),
1161
+ );
1162
+
1163
+ usePlayerStore.setState({ elements: [RETIME_ELEMENT] });
1164
+ diamond.dispatchEvent(
1165
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }),
1166
+ );
1167
+ window.dispatchEvent(
1168
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }),
1169
+ );
1170
+ });
1171
+
1172
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
1173
+ act(() => root.unmount());
1174
+ });
1175
+
681
1176
  // Regression: onClickKeyframe's state updates can re-render the diamond
682
1177
  // button out from under the gesture before the browser auto-synthesizes the
683
1178
  // "click" event that follows a button's pointerdown+pointerup. That orphaned
684
- // click then bubbles to the ancestor clip's onClick, which toggles selection
685
- // off whenever the clip is already selected the state a diamond click
686
- // always happens in so every keyframe click immediately deselected its
687
- // own clip. suppressClickRef lets that ancestor ignore the stray click.
1179
+ // click then bubbles to the ancestor clip's onClick. That stray click can
1180
+ // replace keyframe focus or collapse a marquee selection, so suppressClickRef
1181
+ // lets the ancestor ignore it.
688
1182
  it("arms suppressClickRef synchronously on a keyframe click", () => {
689
1183
  const suppressClickRef = { current: false };
690
- const host = document.createElement("div");
691
- document.body.append(host);
1184
+ const host = createTimelineHost();
692
1185
  const root = createRoot(host);
693
1186
  act(() => {
694
1187
  root.render(
@@ -722,9 +1215,9 @@ describe("TimelineClipDiamonds", () => {
722
1215
  });
723
1216
 
724
1217
  const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
725
- const host = document.createElement("div");
726
- document.body.append(host);
1218
+ const host = createTimelineHost();
727
1219
  const root = createRoot(host);
1220
+ const onSelectSegment = vi.fn();
728
1221
  const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
729
1222
  percentage,
730
1223
  tweenPercentage: percentage,
@@ -757,13 +1250,15 @@ describe("TimelineClipDiamonds", () => {
757
1250
  isSelected
758
1251
  currentPercentage={0}
759
1252
  elementId="clip-1"
1253
+ clipStart={10}
1254
+ clipDuration={10}
760
1255
  selectedKeyframes={new Set()}
761
- onSelectSegment={vi.fn()}
1256
+ onSelectSegment={onSelectSegment}
762
1257
  groupAware
763
1258
  />,
764
1259
  );
765
1260
  });
766
- return { host, root };
1261
+ return { host, onSelectSegment, root };
767
1262
  };
768
1263
 
769
1264
  it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
@@ -776,8 +1271,23 @@ describe("TimelineClipDiamonds", () => {
776
1271
  });
777
1272
 
778
1273
  it("shows the inline ease button on single-animation merged segments", () => {
779
- const { host, root } = renderSegmentLane(false);
1274
+ const { host, onSelectSegment, root } = renderSegmentLane(false);
780
1275
  expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
1276
+ const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
1277
+ expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
1278
+ expect(ease.classList.contains("opacity-0")).toBe(true);
1279
+ act(() => ease.click());
1280
+ expect(onSelectSegment).toHaveBeenCalledOnce();
1281
+ expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
1282
+ act(() => root.unmount());
1283
+ });
1284
+
1285
+ it("ends connectors at the diamond boundaries", () => {
1286
+ const { host, root } = renderSegmentLane(false);
1287
+ const connectors = host.querySelectorAll<HTMLElement>("[data-keyframe-connector]");
1288
+
1289
+ expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]);
1290
+ expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]);
781
1291
  act(() => root.unmount());
782
1292
  });
783
1293
 
@@ -811,8 +1321,7 @@ describe("TimelineClipDiamonds", () => {
811
1321
  it("hides the inline ease button on a segment with no source animation id", () => {
812
1322
  // A runtime-scanned keyframe has no animationId, so there is no tween to
813
1323
  // target; the segment ending on it must not render a (dead) ease button.
814
- const host = document.createElement("div");
815
- document.body.append(host);
1324
+ const host = createTimelineHost();
816
1325
  const root = createRoot(host);
817
1326
  const kf = (percentage: number, animationId?: string) => ({
818
1327
  percentage,