@hyperframes/studio 0.7.90 → 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-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-CquRJgm-.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-Cql15Yur.js +0 -428
@@ -1,5 +1,7 @@
1
- import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
1
+ import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
2
2
  import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
3
+ import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex";
4
+ import type { TimelineElement } from "../store/playerStore";
3
5
 
4
6
  /** Pointer must travel at least this far (either axis) before a pointerdown on
5
7
  * the empty timeline body becomes a marquee drag instead of a plain click. */
@@ -62,23 +64,22 @@ export function getMarqueeRect(
62
64
  /**
63
65
  * A clip's rendered rect in canvas/content coordinates (the same space the
64
66
  * marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
65
- * index within the visible track order (cumulative row top + CLIP_Y).
67
+ * index within the canonical row geometry (cumulative row top + CLIP_Y).
66
68
  * Returns null when the clip's track is not currently displayed.
67
69
  */
68
70
  export function getTimelineClipRect(
69
71
  clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
70
- trackOrder: number[],
72
+ rowGeometry: TimelineRowGeometry,
71
73
  pps: number,
72
74
  contentOrigin: number,
73
- rowHeights: readonly number[] = [],
74
75
  ): Rect | null {
75
- const row = trackOrder.indexOf(clip.track);
76
+ const row = rowGeometry.getRowIndex(clip.track);
76
77
  if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
77
78
  return {
78
79
  left: contentOrigin + clip.start * pps,
79
- top: getTimelineRowTop(row, rowHeights) + CLIP_Y,
80
+ top: rowGeometry.getRowTop(row) + CLIP_Y,
80
81
  width: Math.max(clip.duration * pps, MIN_CLIP_W),
81
- height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2,
82
+ height: TRACK_H - CLIP_Y * 2,
82
83
  };
83
84
  }
84
85
 
@@ -90,29 +91,56 @@ export interface MarqueeSelectionResult {
90
91
  primaryId: string | null;
91
92
  }
92
93
 
94
+ /** Narrow a marquee hit test to the intersecting logical rows and time span. */
95
+ export function getMarqueeClipCandidates(input: {
96
+ clipIndex: TimelineClipIndex;
97
+ rowGeometry: TimelineRowGeometry;
98
+ marquee: Rect;
99
+ pps: number;
100
+ contentOrigin: number;
101
+ }): readonly TimelineElement[] {
102
+ if (!(input.pps > 0) || input.marquee.width <= 0 || input.marquee.height <= 0) return [];
103
+ const lastRow = input.rowGeometry.rowKeys.length - 1;
104
+ const first = Math.max(0, Math.floor(input.rowGeometry.getRowFromY(input.marquee.top)));
105
+ const last = Math.min(
106
+ lastRow,
107
+ Math.floor(input.rowGeometry.getRowFromY(input.marquee.top + input.marquee.height)),
108
+ );
109
+ if (first > last) return [];
110
+ const paddingSeconds = MIN_CLIP_W / input.pps;
111
+ const start = Math.max(
112
+ 0,
113
+ (input.marquee.left - input.contentOrigin) / input.pps - paddingSeconds,
114
+ );
115
+ const end =
116
+ (input.marquee.left + input.marquee.width - input.contentOrigin) / input.pps + paddingSeconds;
117
+ if (end <= start) return [];
118
+
119
+ const candidates: TimelineElement[] = [];
120
+ for (let row = first; row <= last; row += 1) {
121
+ const rowKey = input.rowGeometry.rowKeys[row];
122
+ if (rowKey === undefined) continue;
123
+ candidates.push(...queryTimelineClipIndex(input.clipIndex, rowKey, { start, end }));
124
+ }
125
+ return candidates;
126
+ }
127
+
93
128
  /**
94
129
  * Live marquee selection: every clip whose rendered rect intersects the marquee.
95
130
  * `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
96
131
  */
97
132
  export function computeMarqueeSelection(input: {
98
133
  clips: MarqueeClipInput[];
99
- trackOrder: number[];
134
+ rowGeometry: TimelineRowGeometry;
100
135
  pps: number;
101
136
  contentOrigin: number;
102
137
  marquee: Rect;
103
138
  baseSelection?: Iterable<string>;
104
- rowHeights?: readonly number[];
105
139
  }): MarqueeSelectionResult {
106
140
  const ids = new Set<string>(input.baseSelection ?? []);
107
141
  let primaryId: string | null = null;
108
142
  for (const clip of input.clips) {
109
- const rect = getTimelineClipRect(
110
- clip,
111
- input.trackOrder,
112
- input.pps,
113
- input.contentOrigin,
114
- input.rowHeights,
115
- );
143
+ const rect = getTimelineClipRect(clip, input.rowGeometry, input.pps, input.contentOrigin);
116
144
  if (rect && rectsOverlap(rect, input.marquee)) {
117
145
  ids.add(clip.id);
118
146
  primaryId = clip.id;
@@ -0,0 +1,13 @@
1
+ /** Configure the shared viewport geometry used by timeline gesture hook tests. */
2
+ export function configureTimelineTestViewport(scroll: HTMLElement, scrollHeight: number): void {
3
+ scroll.getBoundingClientRect = () =>
4
+ ({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect;
5
+ Object.defineProperties(scroll, {
6
+ scrollLeft: { configurable: true, writable: true, value: 0 },
7
+ scrollTop: { configurable: true, writable: true, value: 0 },
8
+ scrollWidth: { configurable: true, value: 10_000 },
9
+ scrollHeight: { configurable: true, value: scrollHeight },
10
+ clientWidth: { configurable: true, value: 800 },
11
+ clientHeight: { configurable: true, value: 240 },
12
+ });
13
+ }
@@ -29,10 +29,10 @@ export function getTimelinePreviewElement(
29
29
  element: TimelineElement,
30
30
  resizingClip: ResizingClipState | null,
31
31
  ): TimelineElement {
32
- if (
33
- resizingClip &&
34
- getTimelineElementIdentity(resizingClip.element) === getTimelineElementIdentity(element)
35
- ) {
32
+ const elementIdentity = getTimelineElementIdentity(element);
33
+ const groupPreview = resizingClip?.groupPreview?.find((change) => change.key === elementIdentity);
34
+ if (groupPreview) return { ...element, ...groupPreview };
35
+ if (resizingClip && getTimelineElementIdentity(resizingClip.element) === elementIdentity) {
36
36
  return {
37
37
  ...element,
38
38
  start: resizingClip.previewStart,
@@ -4,7 +4,7 @@ import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import { usePlayerStore } from "../store/playerStore";
7
- import type { ResizingClipState } from "./useTimelineClipDrag";
7
+ import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
8
8
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
9
9
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
10
 
@@ -38,38 +38,77 @@ function renderResizeHarness(
38
38
  usePlayerStore.getState().setSelectedElementIds(new Set(selected));
39
39
 
40
40
  const scroll = document.createElement("div");
41
+ const setPointerCapture = vi.fn();
42
+ scroll.setPointerCapture = setPointerCapture;
41
43
  document.body.append(scroll);
42
44
  const onResizeElement = vi.fn();
45
+ const onMoveElement = vi.fn();
46
+ const onBlockedEditAttempt = vi.fn();
43
47
  const onResizeElements = vi.fn().mockResolvedValue(undefined);
44
48
  let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
49
+ let setDraggedClip: ((s: DraggedClipState | null) => void) | null = null;
50
+ let resizingClip: ResizingClipState | null = null;
51
+ let blockedClipRef: React.RefObject<BlockedClipState | null> | null = null;
52
+ let epoch = 0;
45
53
 
46
- function Harness() {
54
+ function Harness({ sessionEpoch }: { sessionEpoch: number }) {
47
55
  const hook = useTimelineClipDrag({
48
56
  scrollRef: { current: scroll },
49
57
  ppsRef: { current: 100 },
50
58
  durationRef: { current: 100 },
51
59
  trackOrderRef: { current: [0, 1] },
52
60
  onResizeElement,
61
+ onMoveElement,
62
+ onBlockedEditAttempt,
53
63
  onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
54
64
  setShowPopover: vi.fn(),
55
65
  setRangeSelectionRef: { current: vi.fn() },
66
+ sessionEpoch,
56
67
  });
57
68
  setResizingClip = hook.setResizingClip;
69
+ setDraggedClip = hook.setDraggedClip;
70
+ resizingClip = hook.resizingClip;
71
+ blockedClipRef = hook.blockedClipRef;
58
72
  return null;
59
73
  }
60
74
 
61
- const root = mountReactHarness(<Harness />);
75
+ const root = mountReactHarness(<Harness sessionEpoch={epoch} />);
62
76
  const apply = setResizingClip!;
77
+ const dispatchPointer = (type: string, clientX: number, pointerId = 0) => {
78
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY: 0 });
79
+ Object.defineProperty(event, "pointerId", { value: pointerId });
80
+ window.dispatchEvent(event);
81
+ };
63
82
 
64
83
  return {
65
84
  onResizeElement,
85
+ onMoveElement,
86
+ onBlockedEditAttempt,
66
87
  onResizeElements,
88
+ setPointerCapture,
67
89
  storeById(id: string) {
68
90
  return usePlayerStore.getState().elements.find((e) => e.id === id)!;
69
91
  },
70
- startResize(element: TimelineElement, edge: "start" | "end") {
92
+ getResizeProjection() {
93
+ return resizingClip?.groupPreview ?? [];
94
+ },
95
+ getBlockedClip() {
96
+ return blockedClipRef?.current ?? null;
97
+ },
98
+ startBlocked(element: TimelineElement, pointerId = 0) {
99
+ blockedClipRef!.current = {
100
+ pointerId,
101
+ element,
102
+ intent: "move",
103
+ originClientX: 0,
104
+ originClientY: 0,
105
+ started: false,
106
+ };
107
+ },
108
+ startResize(element: TimelineElement, edge: "start" | "end", pointerId = 0) {
71
109
  act(() => {
72
110
  apply({
111
+ pointerId,
73
112
  element,
74
113
  edge,
75
114
  originClientX: 0,
@@ -80,19 +119,57 @@ function renderResizeHarness(
80
119
  });
81
120
  });
82
121
  },
83
- movePointer(clientX: number) {
122
+ startDrag(element: TimelineElement, pointerId = 0) {
123
+ act(() =>
124
+ setDraggedClip?.({
125
+ pointerId,
126
+ element,
127
+ originClientX: 0,
128
+ originClientY: 0,
129
+ originScrollLeft: 0,
130
+ originScrollTop: 0,
131
+ pointerClientX: 0,
132
+ pointerClientY: 0,
133
+ pointerOffsetX: 0,
134
+ pointerOffsetY: 0,
135
+ previewStart: element.start,
136
+ previewTrack: element.track,
137
+ insertRow: null,
138
+ snapTime: null,
139
+ snapType: null,
140
+ started: false,
141
+ }),
142
+ );
143
+ },
144
+ movePointer(clientX: number, pointerId = 0) {
84
145
  act(() => {
85
- window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
146
+ dispatchPointer("pointermove", clientX, pointerId);
147
+ });
148
+ },
149
+ async dropPointer(pointerId = 0) {
150
+ await act(async () => {
151
+ dispatchPointer("pointerup", 0, pointerId);
86
152
  });
87
153
  },
88
- async dropPointer() {
154
+ async moveAndDropPointer(clientX: number, pointerId = 0) {
89
155
  await act(async () => {
90
- window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
156
+ dispatchPointer("pointermove", clientX, pointerId);
157
+ dispatchPointer("pointerup", clientX, pointerId);
91
158
  });
92
159
  },
160
+ cancelPointer(pointerId = 0) {
161
+ act(() => dispatchPointer("pointercancel", 0, pointerId));
162
+ },
163
+ losePointerCapture(pointerId = 0) {
164
+ act(() => dispatchPointer("lostpointercapture", 0, pointerId));
165
+ },
166
+ setSessionEpoch(next: number) {
167
+ epoch = next;
168
+ act(() => root.render(<Harness sessionEpoch={epoch} />));
169
+ },
93
170
  pressEscape() {
94
171
  act(() => {
95
- window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
172
+ scroll.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
96
173
  });
97
174
  },
98
175
  unmount() {
@@ -161,14 +238,131 @@ describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
161
238
  });
162
239
  });
163
240
 
241
+ describe("useTimelineClipDrag — gesture lifecycle", () => {
242
+ it("does not capture a stationary click before the drag threshold", () => {
243
+ const a = el("a", { duration: 2 });
244
+ const h = renderResizeHarness([a], []);
245
+ h.startDrag(a, 7);
246
+ expect(h.setPointerCapture).not.toHaveBeenCalled();
247
+
248
+ h.movePointer(3, 7);
249
+ expect(h.setPointerCapture).not.toHaveBeenCalled();
250
+
251
+ h.movePointer(5, 7);
252
+ expect(h.setPointerCapture).toHaveBeenCalledOnce();
253
+ expect(h.setPointerCapture).toHaveBeenCalledWith(7);
254
+ h.unmount();
255
+ });
256
+
257
+ it("commits the final drag position exactly once", async () => {
258
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
259
+ const a = el("a", { duration: 2 });
260
+ const h = renderResizeHarness([a], []);
261
+ h.startDrag(a, 7);
262
+ await h.moveAndDropPointer(50, 7);
263
+ await h.dropPointer(7);
264
+ expect(h.onMoveElement).toHaveBeenCalledTimes(1);
265
+ expect(h.onMoveElement).toHaveBeenCalledWith(a, expect.objectContaining({ start: 0.5 }));
266
+ expect(warnSpy).toHaveBeenCalledWith(
267
+ expect.stringContaining("only single-clip onMoveElement wired"),
268
+ );
269
+ warnSpy.mockRestore();
270
+ h.unmount();
271
+ });
272
+
273
+ it("ignores a foreign pointer cancellation during a blocked gesture", async () => {
274
+ const a = el("a", { duration: 2 });
275
+ const h = renderResizeHarness([a], []);
276
+ h.startBlocked(a, 7);
277
+ h.movePointer(5, 7);
278
+ expect(h.onBlockedEditAttempt).toHaveBeenCalledOnce();
279
+
280
+ h.cancelPointer(8);
281
+ expect(h.getBlockedClip()?.pointerId).toBe(7);
282
+
283
+ await h.dropPointer(7);
284
+ expect(h.getBlockedClip()).toBeNull();
285
+ h.unmount();
286
+ });
287
+
288
+ it("commits the final same-turn pointer move exactly once", async () => {
289
+ const a = el("a", { duration: 2 });
290
+ const h = renderResizeHarness([a], []);
291
+ h.startResize(a, "end", 7);
292
+ await h.moveAndDropPointer(75, 7);
293
+ await h.dropPointer(7);
294
+ expect(h.onResizeElement).toHaveBeenCalledTimes(1);
295
+ expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.75 }));
296
+ h.unmount();
297
+ });
298
+
299
+ it("ignores another pointer and cancels without committing", async () => {
300
+ const a = el("a", { duration: 2 });
301
+ const h = renderResizeHarness([a], []);
302
+ h.startResize(a, "end", 7);
303
+ h.movePointer(50, 8);
304
+ await h.dropPointer(8);
305
+ expect(h.onResizeElement).not.toHaveBeenCalled();
306
+ h.cancelPointer(7);
307
+ await h.dropPointer(7);
308
+ expect(h.onResizeElement).not.toHaveBeenCalled();
309
+ expect(h.storeById("a").duration).toBe(2);
310
+ h.unmount();
311
+ });
312
+
313
+ it("cancels an active projection when the project epoch changes", () => {
314
+ const a = el("a", { duration: 2 });
315
+ const h = renderResizeHarness([a], []);
316
+ h.startResize(a, "end", 7);
317
+ h.movePointer(50, 7);
318
+ h.setSessionEpoch(1);
319
+ expect(h.getResizeProjection()).toHaveLength(0);
320
+ expect(h.onResizeElement).not.toHaveBeenCalled();
321
+ expect(h.storeById("a").duration).toBe(2);
322
+ h.unmount();
323
+ });
324
+
325
+ it("does not commit a stale clip deleted during the gesture", async () => {
326
+ const a = el("a", { duration: 2 });
327
+ const h = renderResizeHarness([a], []);
328
+ h.startResize(a, "end", 7);
329
+ h.movePointer(50, 7);
330
+ act(() => usePlayerStore.getState().setElements([]));
331
+ await h.dropPointer(7);
332
+ expect(h.onResizeElement).not.toHaveBeenCalled();
333
+ expect(usePlayerStore.getState().elements).toEqual([]);
334
+ h.unmount();
335
+ });
336
+
337
+ it("treats lost capture and unmount as cancellation", async () => {
338
+ const a = el("a", { duration: 2 });
339
+ const lost = renderResizeHarness([a], []);
340
+ lost.startResize(a, "end", 7);
341
+ lost.movePointer(50, 7);
342
+ lost.losePointerCapture(7);
343
+ await lost.dropPointer(7);
344
+ expect(lost.onResizeElement).not.toHaveBeenCalled();
345
+ lost.unmount();
346
+
347
+ const unmounted = renderResizeHarness([a], []);
348
+ unmounted.startResize(a, "end", 9);
349
+ unmounted.movePointer(50, 9);
350
+ unmounted.unmount();
351
+ expect(unmounted.onResizeElement).not.toHaveBeenCalled();
352
+ expect(usePlayerStore.getState().elements[0]?.duration).toBe(2);
353
+ });
354
+ });
355
+
164
356
  describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
165
- it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
357
+ it("previews every member without mutating canonical elements", () => {
166
358
  const { h } = startGroupResize(); // grabbed asks +0.5
167
359
 
168
- // Non-grabbed member is previewed in the store; the grabbed clip renders from
169
- // resizingClip state, so its store value is still the original until commit.
170
- expect(h.storeById("b").duration).toBe(3.5);
360
+ expect(h.getResizeProjection()).toEqual([
361
+ expect.objectContaining({ key: "a", duration: 2.5 }),
362
+ expect.objectContaining({ key: "b", duration: 3.5 }),
363
+ ]);
171
364
  expect(h.storeById("a").duration).toBe(2);
365
+ expect(h.storeById("b").duration).toBe(3);
172
366
  h.unmount();
173
367
  });
174
368
 
@@ -186,6 +380,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
186
380
  { coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
187
381
  );
188
382
  expect(h.storeById("a").duration).toBe(2.5);
383
+ expect(h.getResizeProjection()).toHaveLength(0);
189
384
  expect(h.storeById("b").duration).toBe(3.5);
190
385
  h.unmount();
191
386
  });
@@ -213,7 +408,10 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
213
408
  });
214
409
  h.startResize(a, "end");
215
410
  h.movePointer(50);
216
- expect(h.storeById("b").duration).toBe(3.5);
411
+ expect(h.getResizeProjection()).toEqual(
412
+ expect.arrayContaining([expect.objectContaining({ key: "b", duration: 3.5 })]),
413
+ );
414
+ expect(h.storeById("b").duration).toBe(3);
217
415
 
218
416
  await h.dropPointer();
219
417
  expect(h.storeById("a").duration).toBe(2);
@@ -229,12 +427,16 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
229
427
  h.unmount();
230
428
  });
231
429
 
232
- it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
430
+ it("Escape discards the projection and persists nothing", () => {
233
431
  const { h } = startGroupResize();
234
- expect(h.storeById("b").duration).toBe(3.5); // previewed
432
+ expect(h.getResizeProjection()).toHaveLength(2);
433
+ const sawEscape = vi.fn();
434
+ document.addEventListener("keydown", sawEscape, { once: true });
235
435
 
236
436
  h.pressEscape();
237
- expect(h.storeById("b").duration).toBe(3); // restored
437
+ expect(sawEscape).toHaveBeenCalledOnce();
438
+ expect(h.getResizeProjection()).toHaveLength(0);
439
+ expect(h.storeById("b").duration).toBe(3);
238
440
  expect(h.onResizeElement).not.toHaveBeenCalled();
239
441
  h.unmount();
240
442
  });