@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -3,8 +3,8 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import type { TimelineElement } from "../store/playerStore";
7
- import { TimelineClipFades } from "./TimelineClipFades";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
8
8
  import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
9
9
 
10
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -14,8 +14,15 @@ const mounted: Root[] = [];
14
14
  afterEach(() => {
15
15
  act(() => mounted.splice(0).forEach((root) => root.unmount()));
16
16
  document.body.innerHTML = "";
17
+ usePlayerStore.setState({ currentTime: 0, elements: [], timelineSnapEnabled: true });
17
18
  });
18
19
 
20
+ /** TimelineClip owns the draft; this stands in for it. */
21
+ function Fades(props: { el: TimelineElement; showHandles: boolean; focusable?: boolean }) {
22
+ const fade = useClipFadeDraft(props.el);
23
+ return <TimelineClipFades {...props} pps={100} widthPx={props.el.duration * 100} fade={fade} />;
24
+ }
25
+
19
26
  const clip: TimelineElement = {
20
27
  id: "music",
21
28
  tag: "audio",
@@ -29,7 +36,12 @@ const clip: TimelineElement = {
29
36
 
30
37
  function render(
31
38
  el: TimelineElement,
32
- options: { showHandles?: boolean; provide?: boolean; onClipPointerDown?: () => void } = {},
39
+ options: {
40
+ showHandles?: boolean;
41
+ provide?: boolean;
42
+ focusable?: boolean;
43
+ onClipPointerDown?: () => void;
44
+ } = {},
33
45
  ) {
34
46
  const host = document.createElement("div");
35
47
  document.body.append(host);
@@ -39,36 +51,30 @@ function render(
39
51
  const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
40
52
  const onRevertElementAttributeLive = vi.fn();
41
53
  // Stands in for the TimelineClip button the handles live inside.
42
- const node = (
54
+ const tree = (el: TimelineElement) => (
43
55
  <div data-testid="clip" onPointerDown={options.onClipPointerDown}>
44
- <TimelineClipFades
45
- el={el}
46
- pps={100}
47
- widthPx={el.duration * 100}
48
- showHandles={options.showHandles ?? true}
49
- />
56
+ <Fades el={el} showHandles={options.showHandles ?? true} focusable={options.focusable} />
50
57
  </div>
51
58
  );
52
- act(() => {
53
- root.render(
54
- options.provide === false ? (
55
- node
56
- ) : (
57
- <TimelineEditProvider
58
- value={{
59
- onSetElementAttributeLive,
60
- onSetElementAttributeQuiet,
61
- onRevertElementAttributeLive,
62
- }}
63
- >
64
- {node}
65
- </TimelineEditProvider>
66
- ),
59
+ const wrap = (el: TimelineElement) =>
60
+ options.provide === false ? (
61
+ tree(el)
62
+ ) : (
63
+ <TimelineEditProvider
64
+ value={{
65
+ onSetElementAttributeLive,
66
+ onSetElementAttributeQuiet,
67
+ onRevertElementAttributeLive,
68
+ }}
69
+ >
70
+ {tree(el)}
71
+ </TimelineEditProvider>
67
72
  );
68
- });
73
+ act(() => root.render(wrap(el)));
69
74
  return {
70
75
  host,
71
76
  root,
77
+ rerender: (next: TimelineElement) => act(() => root.render(wrap(next))),
72
78
  onSetElementAttributeLive,
73
79
  onSetElementAttributeQuiet,
74
80
  onRevertElementAttributeLive,
@@ -81,6 +87,19 @@ function pointer(type: string, clientX: number, pointerId = 1, clientY = 0) {
81
87
  return event;
82
88
  }
83
89
 
90
+ /** Pointer down at the first point, then a move to each later one; [clientX, pointerId?]. */
91
+ function press(handle: HTMLElement, down: [number, number?], ...moves: Array<[number, number?]>) {
92
+ act(() => handle.dispatchEvent(pointer("pointerdown", ...down)));
93
+ for (const move of moves) act(() => handle.dispatchEvent(pointer("pointermove", ...move)));
94
+ }
95
+
96
+ function armedHandle(host: HTMLElement, edge: "in" | "out") {
97
+ const handle = host.querySelector<HTMLElement>(`[data-testid="clip-fade-handle-${edge}"]`);
98
+ if (!handle) throw new Error(`expected a fade-${edge} handle`);
99
+ armCapture(handle);
100
+ return handle;
101
+ }
102
+
84
103
  function armCapture(handle: HTMLElement) {
85
104
  let captured = false;
86
105
  Object.defineProperty(handle, "setPointerCapture", { value: () => (captured = true) });
@@ -122,13 +141,9 @@ describe("TimelineClipFades", () => {
122
141
 
123
142
  it("drags the fade-in dot to the right, previewing live and committing once on release", () => {
124
143
  const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
125
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
126
- if (!handle) throw new Error("expected a fade-in handle");
127
- armCapture(handle);
144
+ const handle = armedHandle(host, "in");
128
145
  // 150 px of rightward travel adds 1.5 s to the 1 s fade-in, wherever the press lands.
129
- act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
130
- act(() => handle.dispatchEvent(pointer("pointermove", 200)));
131
- act(() => handle.dispatchEvent(pointer("pointermove", 250)));
146
+ press(handle, [100], [200], [250]);
132
147
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
133
148
  expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
134
149
  // The ramp follows the pointer before anything is persisted.
@@ -143,12 +158,9 @@ describe("TimelineClipFades", () => {
143
158
 
144
159
  it("drags the fade-out dot to the left, growing the fade, and never past the fade-in", () => {
145
160
  const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
146
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
147
- if (!handle) throw new Error("expected a fade-out handle");
148
- armCapture(handle);
161
+ const handle = armedHandle(host, "out");
149
162
  // 300 px of leftward travel adds 3 s to the 2 s fade-out, wherever the press lands.
150
- act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
151
- act(() => handle.dispatchEvent(pointer("pointermove", 700)));
163
+ press(handle, [1000], [700]);
152
164
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
153
165
  // Way past the start: clamps to duration − fadeIn = 9 s.
154
166
  act(() => handle.dispatchEvent(pointer("pointermove", 0)));
@@ -160,11 +172,8 @@ describe("TimelineClipFades", () => {
160
172
 
161
173
  it("removes the attribute when dragged back to zero", () => {
162
174
  const { host, root, onSetElementAttributeQuiet } = render(clip);
163
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
164
- if (!handle) throw new Error("expected a fade-in handle");
165
- armCapture(handle);
166
- act(() => handle.dispatchEvent(pointer("pointerdown", 200)));
167
- act(() => handle.dispatchEvent(pointer("pointermove", 0)));
175
+ const handle = armedHandle(host, "in");
176
+ press(handle, [200], [0]);
168
177
  act(() => handle.dispatchEvent(pointer("pointerup", 0)));
169
178
  expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
170
179
  act(() => root.unmount());
@@ -178,11 +187,8 @@ describe("TimelineClipFades", () => {
178
187
  onRevertElementAttributeLive,
179
188
  onSetElementAttributeQuiet,
180
189
  } = render(clip);
181
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
182
- if (!handle) throw new Error("expected a fade-in handle");
183
- armCapture(handle);
184
- act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
185
- act(() => handle.dispatchEvent(pointer("pointermove", 300)));
190
+ const handle = armedHandle(host, "in");
191
+ press(handle, [100], [300]);
186
192
  act(() => handle.dispatchEvent(pointer("pointercancel", 300)));
187
193
  // Written back live for a host without the revert, then ended through the lanes' revert.
188
194
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
@@ -196,11 +202,8 @@ describe("TimelineClipFades", () => {
196
202
  const elsewhere = document.createElement("button");
197
203
  document.body.append(elsewhere);
198
204
  elsewhere.focus();
199
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
200
- if (!handle) throw new Error("expected a fade-in handle");
201
- armCapture(handle);
202
- act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
203
- act(() => handle.dispatchEvent(pointer("pointermove", 300)));
205
+ const handle = armedHandle(host, "in");
206
+ press(handle, [100], [300]);
204
207
  expect(document.activeElement).toBe(elsewhere);
205
208
  act(() => {
206
209
  document.activeElement?.dispatchEvent(
@@ -221,11 +224,8 @@ describe("TimelineClipFades", () => {
221
224
  ])("restores the fade and saves nothing when released %s", (_, clientX, clientY) => {
222
225
  const { host, root, onSetElementAttributeLive, onRevertElementAttributeLive, ...rest } =
223
226
  render(clip);
224
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
225
- if (!handle) throw new Error("expected a fade-in handle");
226
- armCapture(handle);
227
- act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
228
- act(() => handle.dispatchEvent(pointer("pointermove", 300)));
227
+ const handle = armedHandle(host, "in");
228
+ press(handle, [100], [300]);
229
229
  act(() => handle.dispatchEvent(pointer("pointerup", clientX, 1, clientY)));
230
230
  expect(rest.onSetElementAttributeQuiet).not.toHaveBeenCalled();
231
231
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
@@ -257,12 +257,28 @@ describe("TimelineClipFades", () => {
257
257
  },
258
258
  );
259
259
 
260
- it.each([0.3, 3])("pins the handles to the top corners for a %s s fade-in", (fadeIn) => {
260
+ it.each([
261
+ [0.3, "18px"],
262
+ [3, "288px"],
263
+ ])("puts the fade-in tab at the end of a %s s fade", (fadeIn, left) => {
261
264
  const { host } = render({ ...clip, fadeIn, fadeOut: 0.4 });
262
265
  const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
263
- const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
266
+ const outHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
267
+ // The 24 px box centres on the fade's end: 0.4 s out of a 1000 px clip ends at 960.
268
+ expect(inHandle?.style.left).toBe(left);
269
+ expect(outHandle?.style.left).toBe("948px");
270
+ const tab = inHandle?.firstElementChild as HTMLElement;
271
+ expect(tab.style.left).toBe("10px");
272
+ });
273
+
274
+ it("keeps the tab off the rounded end when there is no fade", () => {
275
+ const { host } = render({ ...clip, fadeIn: undefined, fadeOut: undefined });
276
+ const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
277
+ const outHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
264
278
  expect(inHandle?.style.left).toBe("0px");
265
- expect(fadeOut?.style.left).toBe(`${1000 - 24}px`);
279
+ expect(outHandle?.style.left).toBe("976px");
280
+ // The tab itself sits 7 px in from each end: 5 px is its left edge, 2 px either side of 7.
281
+ expect(inHandle?.querySelector<HTMLElement>(".timeline-fade-tab")?.style.left).toBe("5px");
266
282
  });
267
283
 
268
284
  it("lengthens a fade by the pointer's inward travel from wherever the press lands", () => {
@@ -272,19 +288,17 @@ describe("TimelineClipFades", () => {
272
288
  if (!fadeIn || !fadeOut) throw new Error("expected both handles");
273
289
  armCapture(fadeIn);
274
290
  armCapture(fadeOut);
275
- act(() => fadeIn.dispatchEvent(pointer("pointerdown", 12)));
276
- act(() => fadeIn.dispatchEvent(pointer("pointermove", 162)));
291
+ press(fadeIn, [12], [162]);
277
292
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
278
293
  act(() => fadeIn.dispatchEvent(pointer("pointerup", 162)));
279
- act(() => fadeOut.dispatchEvent(pointer("pointerdown", 988, 2)));
280
- act(() => fadeOut.dispatchEvent(pointer("pointermove", 888, 2)));
294
+ press(fadeOut, [988, 2], [888, 2]);
281
295
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "3");
282
296
  });
283
297
 
284
298
  it("paints the handles above the clip's waveform and thumbnail layers", () => {
285
299
  const { host } = render(clip);
286
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
287
- expect(Number(handle?.style.zIndex)).toBeGreaterThan(10);
300
+ const layer = host.querySelector<HTMLElement>('[data-testid="clip"]')?.firstElementChild;
301
+ expect(Number((layer as HTMLElement).style.zIndex)).toBeGreaterThan(10);
288
302
  });
289
303
 
290
304
  it("splits a clip narrower than two targets between the handles", () => {
@@ -293,19 +307,307 @@ describe("TimelineClipFades", () => {
293
307
  const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
294
308
  expect([fadeIn?.style.left, fadeIn?.style.width]).toEqual(["0px", "15px"]);
295
309
  expect([fadeOut?.style.left, fadeOut?.style.width]).toEqual(["15px", "15px"]);
296
- const dot = fadeIn?.firstElementChild as HTMLElement;
297
- expect(getComputedStyle(dot).width).toBe("10px");
298
- // Layout is not real here; tests/e2e/fade-handles.mjs measures the box in Chrome.
299
- expect(getComputedStyle(dot).flexShrink).toBe("0");
300
- expect(getComputedStyle(dot).pointerEvents).toBe("none");
310
+ const tab = fadeIn?.firstElementChild as HTMLElement;
311
+ expect([tab.style.width, tab.style.height]).toEqual(["4px", "15px"]);
312
+ // Layout is not real here; tests/e2e/fade-handles.mjs measures the tab in Chrome.
313
+ expect(tab.style.pointerEvents).toBe("none");
314
+ });
315
+
316
+ it("snaps the fade's end to the playhead within the timeline's snap radius", () => {
317
+ usePlayerStore.setState({ currentTime: 4.5 });
318
+ const { host, onSetElementAttributeLive } = render(clip);
319
+ const handle = armedHandle(host, "in");
320
+ // 146 px makes a 2.46 s fade ending at 4.46 s, 4 px from the playhead: it lands on 4.5.
321
+ press(handle, [100], [246]);
322
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
323
+ });
324
+
325
+ it("leaves the fade unsnapped when the timeline's snapping is off", () => {
326
+ usePlayerStore.setState({ currentTime: 4.5, timelineSnapEnabled: false });
327
+ const { host, onSetElementAttributeLive } = render(clip);
328
+ const handle = armedHandle(host, "in");
329
+ press(handle, [100], [246]);
330
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.46");
331
+ });
332
+
333
+ it("snaps to another clip's edge", () => {
334
+ usePlayerStore.setState({
335
+ elements: [clip, { id: "title", tag: "div", start: 6, duration: 2, track: 0 }],
336
+ });
337
+ const { host, onSetElementAttributeLive } = render(clip);
338
+ const handle = armedHandle(host, "out");
339
+ // The 2 s fade-out starts at 10 s; 395 px left moves that to 6.05 s, onto the title's start.
340
+ press(handle, [1000], [605]);
341
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "6");
342
+ });
343
+
344
+ it.each([
345
+ [1, null],
346
+ [undefined, "0.5"],
347
+ ])("double-click on a %s s fade-in saves %s", (fadeIn, saved) => {
348
+ const { host, onSetElementAttributeQuiet } = render({ ...clip, fadeIn });
349
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
350
+ act(() => handle?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })));
351
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1);
352
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(
353
+ { ...clip, fadeIn },
354
+ "data-fade-in",
355
+ saved,
356
+ "Fade in",
357
+ );
358
+ });
359
+
360
+ const key = (handle: HTMLElement | null, type: "keydown" | "keyup", init: KeyboardEventInit) =>
361
+ act(() => {
362
+ handle?.dispatchEvent(new KeyboardEvent(type, { ...init, bubbles: true }));
363
+ });
364
+
365
+ it.each([
366
+ [{ key: "ArrowRight" }, "1.1"],
367
+ [{ key: "ArrowRight", shiftKey: true }, "2"],
368
+ [{ key: "ArrowLeft" }, "0.9"],
369
+ [{ key: "Home" }, null],
370
+ [{ key: "Delete" }, null],
371
+ [{ key: "Backspace" }, null],
372
+ [{ key: "End" }, "8"],
373
+ ])("saves a key press once it is released: %o", (init, saved) => {
374
+ const outer = vi.fn();
375
+ const { host, onSetElementAttributeQuiet } = render(clip, { focusable: true });
376
+ document.body.addEventListener("keydown", outer);
377
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
378
+ expect(handle?.tabIndex).toBe(0);
379
+ key(handle, "keydown", init);
380
+ expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
381
+ key(handle, "keyup", init);
382
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1);
383
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", saved, "Fade in");
384
+ // The timeline's own arrow-key handling never sees a key the handle used.
385
+ expect(outer).not.toHaveBeenCalled();
386
+ });
387
+
388
+ it.each(["ArrowRight", "End"])("saves nothing for %s on a lone fade past the clip's end", (k) => {
389
+ const long = { ...clip, duration: 3, fadeIn: 5, fadeOut: undefined };
390
+ const { host, onSetElementAttributeQuiet } = render(long, { focusable: true });
391
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
392
+ key(handle, "keydown", { key: k });
393
+ key(handle, "keyup", { key: k });
394
+ expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("previews a held key live and saves it as one step", () => {
398
+ const { host, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip, {
399
+ focusable: true,
400
+ });
401
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
402
+ for (const repeat of [false, true, true]) key(handle, "keydown", { key: "ArrowRight", repeat });
403
+ expect(onSetElementAttributeLive.mock.calls.map(([, , value]) => value)).toEqual([
404
+ "1.1",
405
+ "1.2",
406
+ "1.3",
407
+ ]);
408
+ key(handle, "keyup", { key: "ArrowRight" });
409
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1);
410
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", "1.3", "Fade in");
411
+ });
412
+
413
+ it("ignores keys while the handle is being dragged", () => {
414
+ const { host, onSetElementAttributeQuiet } = render(clip, { focusable: true });
415
+ const handle = armedHandle(host, "in");
416
+ press(handle, [100], [200]);
417
+ key(handle, "keydown", { key: "Home" });
418
+ key(handle, "keyup", { key: "Home" });
419
+ expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
420
+ });
421
+
422
+ it("drops the draft when the save fails, leaving the live value to the save path", async () => {
423
+ const { host, onSetElementAttributeQuiet, onRevertElementAttributeLive } = render(clip);
424
+ onSetElementAttributeQuiet.mockResolvedValueOnce({ status: "failed", reason: "disk full" });
425
+ const handle = armedHandle(host, "in");
426
+ press(handle, [100], [300]);
427
+ act(() => handle.dispatchEvent(pointer("pointerup", 300)));
428
+ await act(async () => {});
429
+ expect(onRevertElementAttributeLive).not.toHaveBeenCalled();
430
+ expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
431
+ "0,0 100,0 0,100",
432
+ );
433
+ });
434
+
435
+ it("steps from a pressed value whose save has not landed yet", () => {
436
+ const { host, onSetElementAttributeQuiet } = render(clip, { focusable: true });
437
+ onSetElementAttributeQuiet.mockReturnValue(new Promise(() => {}));
438
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
439
+ for (let tap = 0; tap < 2; tap++) {
440
+ key(handle, "keydown", { key: "ArrowRight" });
441
+ key(handle, "keyup", { key: "ArrowRight" });
442
+ }
443
+ expect(onSetElementAttributeQuiet.mock.calls.map(([, , value]) => value)).toEqual([
444
+ "1.1",
445
+ "1.2",
446
+ ]);
447
+ });
448
+
449
+ it("saves a held key before a press on the handle starts a drag", () => {
450
+ const { host, onSetElementAttributeQuiet } = render(clip, { focusable: true });
451
+ const handle = armedHandle(host, "in");
452
+ key(handle, "keydown", { key: "ArrowRight" });
453
+ press(handle, [100]);
454
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", "1.1", "Fade in");
455
+ });
456
+
457
+ it("keeps a handle drawn while it is dragged to where it can no longer move", () => {
458
+ // A fade-in that fills the clip next to a 2 s fade-out, as a trim leaves it.
459
+ const trimmed = { ...clip, fadeIn: 10, fadeOut: 2 };
460
+ const { host, onSetElementAttributeQuiet } = render(trimmed);
461
+ const handle = armedHandle(host, "out");
462
+ // 200 px right shrinks the 2 s fade-out to 0, where it has no room left to move.
463
+ press(handle, [800], [1000]);
464
+ act(() => handle.dispatchEvent(pointer("pointerup", 1000)));
465
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(
466
+ trimmed,
467
+ "data-fade-out",
468
+ null,
469
+ "Fade out",
470
+ );
471
+ });
472
+
473
+ it("hides a handle with less than one 0.01 s step to move", () => {
474
+ const { host } = render({ ...clip, duration: 10.0333, fadeIn: 10.03, fadeOut: undefined });
475
+ expect(host.querySelector('[data-testid="clip-fade-handle-out"]')).toBeNull();
476
+ });
477
+
478
+ it("never lets a grow key shrink a fade when the pair overruns the clip", () => {
479
+ const overrun = { ...clip, fadeIn: 2, fadeOut: 10 };
480
+ const { host, onSetElementAttributeQuiet } = render(overrun, { focusable: true });
481
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
482
+ key(handle, "keydown", { key: "ArrowRight" });
483
+ key(handle, "keyup", { key: "ArrowRight" });
484
+ expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
485
+ });
486
+
487
+ it("keeps stepping from each press while earlier saves land", async () => {
488
+ const saves: Array<() => void> = [];
489
+ const { host, onSetElementAttributeQuiet, rerender } = render(clip, { focusable: true });
490
+ onSetElementAttributeQuiet.mockImplementation(
491
+ () => new Promise<undefined>((resolve) => saves.push(() => resolve(undefined))),
492
+ );
493
+ const handle = () => host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
494
+ const tap = () => {
495
+ key(handle(), "keydown", { key: "ArrowRight" });
496
+ key(handle(), "keyup", { key: "ArrowRight" });
497
+ };
498
+ tap();
499
+ tap();
500
+ // The first save lands: the store re-reads 1.1 while 1.2 is still on its way.
501
+ rerender({ ...clip, fadeIn: 1.1 });
502
+ await act(async () => saves[0]());
503
+ tap();
504
+ expect(onSetElementAttributeQuiet.mock.calls.map(([, , value]) => value)).toEqual([
505
+ "1.1",
506
+ "1.2",
507
+ "1.3",
508
+ ]);
509
+ });
510
+
511
+ it("keeps a focused handle drawn when a key takes it to where it cannot move", () => {
512
+ const trimmed = { ...clip, fadeIn: 10, fadeOut: 2 };
513
+ const { host, onSetElementAttributeQuiet } = render(trimmed, { focusable: true });
514
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
515
+ act(() => handle?.focus());
516
+ key(handle, "keydown", { key: "Home" });
517
+ key(handle, "keyup", { key: "Home" });
518
+ expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(
519
+ trimmed,
520
+ "data-fade-out",
521
+ null,
522
+ "Fade out",
523
+ );
524
+ });
525
+
526
+ it("never lets a grow drag shrink a fade when the pair overruns the clip", () => {
527
+ const trimmed = { ...clip, fadeIn: 10, fadeOut: 2 };
528
+ const { host, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(trimmed);
529
+ const handle = armedHandle(host, "out");
530
+ // Leftward grows a fade-out; the fade-in already fills the clip, so it holds at 2 s.
531
+ press(handle, [900], [880]);
532
+ act(() => handle.dispatchEvent(pointer("pointerup", 880)));
533
+ expect(onSetElementAttributeLive).not.toHaveBeenCalledWith(trimmed, "data-fade-out", null);
534
+ expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
535
+ });
536
+
537
+ it("keeps a pending save's draft through a click that never moves", () => {
538
+ const { host, onSetElementAttributeQuiet } = render({ ...clip, fadeIn: undefined });
539
+ onSetElementAttributeQuiet.mockReturnValue(new Promise(() => {}));
540
+ const handle = armedHandle(host, "in");
541
+ const doubleClick = () => {
542
+ press(handle, [12]);
543
+ act(() => handle.dispatchEvent(pointer("pointerup", 12)));
544
+ act(() => handle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })));
545
+ };
546
+ doubleClick();
547
+ doubleClick();
548
+ // Add 0.5 s, then take it away again, though the first save never landed.
549
+ expect(onSetElementAttributeQuiet.mock.calls.map(([, , value]) => value)).toEqual([
550
+ "0.5",
551
+ null,
552
+ ]);
553
+ });
554
+
555
+ it("shrinks a single fade longer than its clip from the clip's end, with no dead travel", () => {
556
+ const long = { ...clip, duration: 3, fadeIn: 5, fadeOut: undefined };
557
+ const { host, onSetElementAttributeLive } = render(long);
558
+ const handle = armedHandle(host, "in");
559
+ press(handle, [200], [195], [100]);
560
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(long, "data-fade-in", "2");
561
+ });
562
+
563
+ it("gives a fade that fills the clip the only handle at its end", () => {
564
+ const { host } = render({ ...clip, fadeIn: 10, fadeOut: undefined });
565
+ // The fade-out can neither grow nor shrink, so it does not sit on top of the fade-in's tab.
566
+ expect(host.querySelector('[data-testid="clip-fade-handle-out"]')).toBeNull();
567
+ const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
568
+ expect([inHandle?.style.left, inHandle?.style.width]).toEqual(["976px", "24px"]);
569
+ });
570
+
571
+ it("splits two nearby boxes at the midpoint between their tabs on a tiny clip", () => {
572
+ // A 40 px clip with a 30 px fade-in: tabs at 30 and 33 px, boxes meet at 31.5.
573
+ const { host } = render({ ...clip, duration: 0.4, fadeIn: 0.3, fadeOut: undefined });
574
+ const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
575
+ const outHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
576
+ expect([inHandle?.style.left, inHandle?.style.width]).toEqual(["11.5px", "20px"]);
577
+ expect([outHandle?.style.left, outHandle?.style.width]).toEqual(["31.5px", "8.5px"]);
578
+ });
579
+
580
+ it("keeps a long fade's hit box on its tab", () => {
581
+ const { host } = render({ ...clip, fadeIn: 8, fadeOut: 0.4 });
582
+ // The 8 s fade ends at 800 px; its 24 px box centres there, not in its half.
583
+ expect(host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]')?.style.left).toBe(
584
+ "788px",
585
+ );
586
+ });
587
+
588
+ it("never snaps a fade to its own clip's edges", () => {
589
+ usePlayerStore.setState({
590
+ currentTime: 2,
591
+ elements: [{ id: "before", tag: "audio", start: 0, duration: 2, track: 1 }, clip],
592
+ });
593
+ const { host, onSetElementAttributeLive } = render(clip);
594
+ const handle = armedHandle(host, "in");
595
+ // 0.05 s from the clip's start, where the playhead and the neighbour's end both sit.
596
+ press(handle, [100], [5]);
597
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "0.05");
598
+ });
599
+
600
+ it("keeps an unselected clip's handles out of the tab order", () => {
601
+ const { host } = render(clip);
602
+ expect(host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]')?.tabIndex).toBe(
603
+ -1,
604
+ );
301
605
  });
302
606
 
303
607
  it("does not let a press on the dot start the clip's own move gesture", () => {
304
608
  const outer = vi.fn();
305
609
  const { host, root } = render(clip, { onClipPointerDown: outer });
306
- const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
307
- if (!handle) throw new Error("expected a fade-in handle");
308
- armCapture(handle);
610
+ const handle = armedHandle(host, "in");
309
611
  act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
310
612
  expect(outer).not.toHaveBeenCalled();
311
613
  act(() => root.unmount());