@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
@@ -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()) {
50
+ const host = createTimelineHost();
23
51
  const root = createRoot(host);
24
52
  act(() => {
25
53
  root.render(
@@ -46,6 +74,78 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
46
74
  return { host, root, onClickKeyframe };
47
75
  }
48
76
 
77
+ function renderRetimeLane(
78
+ onMoveKeyframe = vi.fn().mockResolvedValue(true),
79
+ strict = false,
80
+ options: {
81
+ elementId?: string;
82
+ host?: HTMLDivElement;
83
+ keyframes?: TimelineDiamondKeyframe[];
84
+ } = {},
85
+ ) {
86
+ const elementId = options.elementId ?? RETIME_ELEMENT.id;
87
+ if (!options.host) {
88
+ usePlayerStore.setState({ elements: [{ ...RETIME_ELEMENT, id: elementId, key: elementId }] });
89
+ }
90
+ const host = options.host ?? createTimelineHost();
91
+ const keyframes = options.keyframes ?? [
92
+ {
93
+ percentage: 0,
94
+ tweenPercentage: 0,
95
+ propertyGroup: "position",
96
+ animationId: "anim-1",
97
+ properties: { x: 0 },
98
+ },
99
+ {
100
+ percentage: 50,
101
+ tweenPercentage: 50,
102
+ propertyGroup: "position",
103
+ animationId: "anim-1",
104
+ properties: { x: 100 },
105
+ },
106
+ {
107
+ percentage: 100,
108
+ tweenPercentage: 100,
109
+ propertyGroup: "position",
110
+ animationId: "anim-1",
111
+ properties: { x: 200 },
112
+ },
113
+ ];
114
+ const onClickKeyframe = vi.fn();
115
+ const mountLane = () => {
116
+ const laneHost = document.createElement("div");
117
+ host.append(laneHost);
118
+ const root = createRoot(laneHost);
119
+ const lane = (
120
+ <TimelineDiamondLane
121
+ keyframesData={{
122
+ format: "percentage",
123
+ keyframes,
124
+ }}
125
+ clipWidthPx={200}
126
+ clipHeightPx={48}
127
+ accentColor="#4ba3d2"
128
+ isSelected
129
+ currentPercentage={0}
130
+ elementId={elementId}
131
+ selectedKeyframes={new Set()}
132
+ onClickKeyframe={onClickKeyframe}
133
+ onMoveKeyframe={onMoveKeyframe}
134
+ groupAware
135
+ />
136
+ );
137
+ act(() => {
138
+ root.render(strict ? <React.StrictMode>{lane}</React.StrictMode> : lane);
139
+ });
140
+ const diamond = laneHost.querySelector<HTMLButtonElement>(
141
+ `button[title="${keyframes[1]?.percentage}%"]`,
142
+ );
143
+ expect(diamond).not.toBeNull();
144
+ return { diamond: diamond!, root };
145
+ };
146
+ return { ...mountLane(), host, mountLane, onClickKeyframe, onMoveKeyframe };
147
+ }
148
+
49
149
  describe("TimelineClipDiamonds", () => {
50
150
  it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
51
151
  const host = document.createElement("div");
@@ -161,6 +261,23 @@ describe("TimelineClipDiamonds", () => {
161
261
  act(() => root.unmount());
162
262
  });
163
263
 
264
+ it("publishes retime previews after StrictMode effect replay", () => {
265
+ const { diamond, host, root } = renderRetimeLane(undefined, true);
266
+ const initialLeft = diamond.style.left;
267
+ act(() => {
268
+ diamond.dispatchEvent(
269
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
270
+ );
271
+ window.dispatchEvent(
272
+ pointerEvent("pointermove", { bubbles: true, clientX: 120, pointerId: 7 }),
273
+ );
274
+ });
275
+ expect(host.querySelector<HTMLButtonElement>('button[title="50%"]')?.style.left).not.toBe(
276
+ initialLeft,
277
+ );
278
+ act(() => root.unmount());
279
+ });
280
+
164
281
  it("treats primary pointerup without drag as a keyframe click", () => {
165
282
  const { host, root, onClickKeyframe } = renderDiamonds();
166
283
  const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
@@ -246,8 +363,7 @@ describe("TimelineClipDiamonds", () => {
246
363
  it("treats a drag-armed press that resolves to a no-op move as a click", () => {
247
364
  const onClickKeyframe = vi.fn();
248
365
  const onMoveKeyframe = vi.fn();
249
- const host = document.createElement("div");
250
- document.body.append(host);
366
+ const host = createTimelineHost();
251
367
  const root = createRoot(host);
252
368
  act(() => {
253
369
  root.render(
@@ -314,8 +430,7 @@ describe("TimelineClipDiamonds", () => {
314
430
  it("reselects a retimed keyframe with its post-move tween percentage", () => {
315
431
  const onClickKeyframe = vi.fn();
316
432
  const onMoveKeyframe = vi.fn().mockResolvedValue(true);
317
- const host = document.createElement("div");
318
- document.body.append(host);
433
+ const host = createTimelineHost();
319
434
  const root = createRoot(host);
320
435
  act(() => {
321
436
  root.render(
@@ -474,8 +589,7 @@ describe("TimelineClipDiamonds", () => {
474
589
 
475
590
  it("composes a rapid second retime from the pending position", () => {
476
591
  const onMoveKeyframe = vi.fn().mockResolvedValue(true);
477
- const host = document.createElement("div");
478
- document.body.append(host);
592
+ const host = createTimelineHost();
479
593
  const root = createRoot(host);
480
594
  act(() => {
481
595
  root.render(
@@ -548,16 +662,120 @@ describe("TimelineClipDiamonds", () => {
548
662
  },
549
663
  85,
550
664
  );
665
+
666
+ act(() => {
667
+ usePlayerStore.setState({ timelineSessionEpoch: 1 });
668
+ diamond!.dispatchEvent(
669
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
670
+ );
671
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
672
+ });
673
+ expect(onMoveKeyframe).toHaveBeenNthCalledWith(
674
+ 3,
675
+ expect.objectContaining({ percentage: 50 }),
676
+ 60,
677
+ );
551
678
  act(() => root.unmount());
552
679
  });
553
680
 
681
+ it("preserves another element's pending retime when the active source is removed", () => {
682
+ const host = createTimelineHost();
683
+ const elementA = { ...RETIME_ELEMENT, id: "clip-a", key: "clip-a" };
684
+ const elementB = { ...RETIME_ELEMENT, id: "clip-b", key: "clip-b" };
685
+ usePlayerStore.setState({ elements: [elementA, elementB] });
686
+ const laneA = renderRetimeLane(vi.fn().mockResolvedValue(true), false, {
687
+ elementId: "clip-a",
688
+ host,
689
+ });
690
+ const onMoveB = vi.fn().mockResolvedValue(true);
691
+ const laneB = renderRetimeLane(onMoveB, false, { elementId: "clip-b", host });
692
+
693
+ act(() => {
694
+ laneB.diamond.dispatchEvent(
695
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
696
+ );
697
+ window.dispatchEvent(
698
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }),
699
+ );
700
+
701
+ laneA.diamond.dispatchEvent(
702
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
703
+ );
704
+ usePlayerStore.setState({ elements: [elementB] });
705
+
706
+ laneB.diamond.dispatchEvent(
707
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 11 }),
708
+ );
709
+ window.dispatchEvent(
710
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170, pointerId: 11 }),
711
+ );
712
+ });
713
+
714
+ expect(onMoveB).toHaveBeenNthCalledWith(2, expect.objectContaining({ percentage: 75 }), 85);
715
+ act(() => {
716
+ laneA.root.unmount();
717
+ laneB.root.unmount();
718
+ });
719
+ });
720
+
721
+ it("retires a pending retime once the cache exposes its destination identity", () => {
722
+ const first = renderRetimeLane();
723
+ act(() => {
724
+ first.diamond.dispatchEvent(
725
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
726
+ );
727
+ window.dispatchEvent(
728
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }),
729
+ );
730
+ });
731
+ expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(1);
732
+ act(() => first.root.unmount());
733
+
734
+ const destination = renderRetimeLane(vi.fn().mockResolvedValue(true), false, {
735
+ host: first.host,
736
+ keyframes: [
737
+ {
738
+ percentage: 0,
739
+ tweenPercentage: 0,
740
+ propertyGroup: "position",
741
+ animationId: "anim-1",
742
+ properties: { x: 0 },
743
+ },
744
+ {
745
+ percentage: 75,
746
+ tweenPercentage: 75,
747
+ propertyGroup: "position",
748
+ animationId: "anim-1",
749
+ properties: { x: 100 },
750
+ },
751
+ {
752
+ percentage: 100,
753
+ tweenPercentage: 100,
754
+ propertyGroup: "position",
755
+ animationId: "anim-1",
756
+ properties: { x: 200 },
757
+ },
758
+ ],
759
+ });
760
+ act(() => {
761
+ destination.diamond.dispatchEvent(
762
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 9 }),
763
+ );
764
+ });
765
+ expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(0);
766
+
767
+ act(() => {
768
+ window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 9 }));
769
+ destination.root.unmount();
770
+ });
771
+ });
772
+
554
773
  it.each([
555
774
  ["returns false", () => Promise.resolve(false)],
556
775
  ["rejects", () => Promise.reject(new Error("retime failed"))],
557
776
  ])("clears a failed pending retime when the callback %s", async (_label, settle) => {
558
777
  const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
559
- const host = document.createElement("div");
560
- document.body.append(host);
778
+ const host = createTimelineHost();
561
779
  const root = createRoot(host);
562
780
  act(() => {
563
781
  root.render(
@@ -678,6 +896,215 @@ describe("TimelineClipDiamonds", () => {
678
896
  act(() => root.unmount());
679
897
  });
680
898
 
899
+ it("commits once from the stable viewport after the source lane unmounts", () => {
900
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
901
+
902
+ act(() => {
903
+ diamond.dispatchEvent(
904
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
905
+ );
906
+ root.unmount();
907
+ window.dispatchEvent(
908
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
909
+ );
910
+ window.dispatchEvent(
911
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }),
912
+ );
913
+ });
914
+
915
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(
916
+ {
917
+ percentage: 50,
918
+ tweenPercentage: 50,
919
+ propertyGroup: "position",
920
+ animationId: "anim-1",
921
+ },
922
+ 60,
923
+ );
924
+ });
925
+
926
+ it("keeps the retime preview coherent when the source lane remounts", () => {
927
+ const { diamond, mountLane, onMoveKeyframe, root } = renderRetimeLane();
928
+
929
+ act(() => {
930
+ diamond.dispatchEvent(
931
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
932
+ );
933
+ window.dispatchEvent(
934
+ pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
935
+ );
936
+ });
937
+ const beforeUnmountLeft = Number.parseFloat(diamond.style.left);
938
+
939
+ act(() => {
940
+ root.unmount();
941
+ window.dispatchEvent(
942
+ pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }),
943
+ );
944
+ });
945
+ const { diamond: remountedDiamond, root: remountedRoot } = mountLane();
946
+ const remountedLeft = Number.parseFloat(remountedDiamond.style.left);
947
+ expect(remountedLeft).toBeGreaterThan(beforeUnmountLeft);
948
+
949
+ act(() => {
950
+ window.dispatchEvent(
951
+ pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }),
952
+ );
953
+ });
954
+ expect(Number.parseFloat(remountedDiamond.style.left)).toBeGreaterThan(remountedLeft);
955
+
956
+ act(() => {
957
+ window.dispatchEvent(
958
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }),
959
+ );
960
+ });
961
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 80);
962
+ act(() => remountedRoot.unmount());
963
+ });
964
+
965
+ it("includes horizontal viewport scrolling in the retime destination", () => {
966
+ const { diamond, host, onMoveKeyframe, root } = renderRetimeLane();
967
+
968
+ act(() => {
969
+ diamond.dispatchEvent(
970
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
971
+ );
972
+ host.scrollLeft = 20;
973
+ window.dispatchEvent(
974
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
975
+ );
976
+ });
977
+
978
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 60);
979
+ act(() => root.unmount());
980
+ });
981
+
982
+ it("auto-scrolls horizontally without virtualizing the source row away", () => {
983
+ let frame: FrameRequestCallback | null = null;
984
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
985
+ frame = callback;
986
+ return 1;
987
+ });
988
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
989
+ const { diamond, host, root } = renderRetimeLane();
990
+ configureTimelineTestViewport(host, 10_000);
991
+
992
+ act(() => {
993
+ diamond.dispatchEvent(
994
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
995
+ );
996
+ window.dispatchEvent(
997
+ pointerEvent("pointermove", {
998
+ bubbles: true,
999
+ button: 0,
1000
+ clientX: 790,
1001
+ clientY: 239,
1002
+ pointerId: 7,
1003
+ }),
1004
+ );
1005
+ });
1006
+ expect(frame).not.toBeNull();
1007
+ act(() => frame?.(0));
1008
+ expect(host.scrollLeft).toBeGreaterThan(0);
1009
+ expect(host.scrollTop).toBe(0);
1010
+
1011
+ act(() => {
1012
+ window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 7 }));
1013
+ root.unmount();
1014
+ });
1015
+ });
1016
+
1017
+ it("ignores another pointer and lets the owning pointer finish", () => {
1018
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
1019
+
1020
+ act(() => {
1021
+ diamond.dispatchEvent(
1022
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1023
+ );
1024
+ window.dispatchEvent(
1025
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 8 }),
1026
+ );
1027
+ window.dispatchEvent(
1028
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1029
+ );
1030
+ });
1031
+
1032
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
1033
+ act(() => root.unmount());
1034
+ });
1035
+
1036
+ it("cancels without mutation on pointer cancel or Escape and allows the next retime", () => {
1037
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
1038
+
1039
+ act(() => {
1040
+ diamond.dispatchEvent(
1041
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1042
+ );
1043
+ window.dispatchEvent(
1044
+ pointerEvent("pointercancel", {
1045
+ bubbles: true,
1046
+ button: 0,
1047
+ clientX: 120,
1048
+ pointerId: 7,
1049
+ }),
1050
+ );
1051
+ window.dispatchEvent(
1052
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1053
+ );
1054
+
1055
+ diamond.dispatchEvent(
1056
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
1057
+ );
1058
+ window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
1059
+ window.dispatchEvent(
1060
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }),
1061
+ );
1062
+
1063
+ diamond.dispatchEvent(
1064
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }),
1065
+ );
1066
+ window.dispatchEvent(
1067
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }),
1068
+ );
1069
+ });
1070
+
1071
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
1072
+ act(() => root.unmount());
1073
+ });
1074
+
1075
+ it("cancels on project switch or source removal without poisoning the next gesture", () => {
1076
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
1077
+
1078
+ act(() => {
1079
+ diamond.dispatchEvent(
1080
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
1081
+ );
1082
+ usePlayerStore.setState({ timelineSessionEpoch: 1 });
1083
+ window.dispatchEvent(
1084
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
1085
+ );
1086
+
1087
+ diamond.dispatchEvent(
1088
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
1089
+ );
1090
+ usePlayerStore.setState({ elements: [] });
1091
+ window.dispatchEvent(
1092
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }),
1093
+ );
1094
+
1095
+ usePlayerStore.setState({ elements: [RETIME_ELEMENT] });
1096
+ diamond.dispatchEvent(
1097
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }),
1098
+ );
1099
+ window.dispatchEvent(
1100
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }),
1101
+ );
1102
+ });
1103
+
1104
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
1105
+ act(() => root.unmount());
1106
+ });
1107
+
681
1108
  // Regression: onClickKeyframe's state updates can re-render the diamond
682
1109
  // button out from under the gesture before the browser auto-synthesizes the
683
1110
  // "click" event that follows a button's pointerdown+pointerup. That orphaned
@@ -687,8 +1114,7 @@ describe("TimelineClipDiamonds", () => {
687
1114
  // own clip. suppressClickRef lets that ancestor ignore the stray click.
688
1115
  it("arms suppressClickRef synchronously on a keyframe click", () => {
689
1116
  const suppressClickRef = { current: false };
690
- const host = document.createElement("div");
691
- document.body.append(host);
1117
+ const host = createTimelineHost();
692
1118
  const root = createRoot(host);
693
1119
  act(() => {
694
1120
  root.render(
@@ -722,8 +1148,7 @@ describe("TimelineClipDiamonds", () => {
722
1148
  });
723
1149
 
724
1150
  const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
725
- const host = document.createElement("div");
726
- document.body.append(host);
1151
+ const host = createTimelineHost();
727
1152
  const root = createRoot(host);
728
1153
  const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
729
1154
  percentage,
@@ -811,8 +1236,7 @@ describe("TimelineClipDiamonds", () => {
811
1236
  it("hides the inline ease button on a segment with no source animation id", () => {
812
1237
  // A runtime-scanned keyframe has no animationId, so there is no tween to
813
1238
  // target; the segment ending on it must not render a (dead) ease button.
814
- const host = document.createElement("div");
815
- document.body.append(host);
1239
+ const host = createTimelineHost();
816
1240
  const root = createRoot(host);
817
1241
  const kf = (percentage: number, animationId?: string) => ({
818
1242
  percentage,