@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
12
12
  import {
13
13
  AudioMeterStrip,
14
14
  type AudioMeterStripProps,
15
+ CLIP_LATCH_PEAK,
16
+ stepClipLatch,
15
17
  evictGoneMeterState,
16
18
  followMeterHook,
17
19
  stepAndPaintStrips,
@@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => {
416
418
  expect(state.has("loud")).toBe(true);
417
419
  });
418
420
  });
421
+
422
+ describe("CLIP light", () => {
423
+ const hookAt = (peak: number) => ({
424
+ start: vi.fn(),
425
+ stop: vi.fn(),
426
+ read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })),
427
+ });
428
+
429
+ it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => {
430
+ usePlayerStore.setState({ elements: [clip({})] });
431
+ const hook = hookAt(1);
432
+ setHook(hook);
433
+ const { host } = mount();
434
+ const light = host.querySelector<HTMLElement>("[data-testid=meter-clip-light]");
435
+ expect(light?.dataset.lit).toBe("false");
436
+ tick();
437
+ expect(light?.dataset.lit).toBe("true");
438
+ hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} });
439
+ tick();
440
+ expect(light?.dataset.lit).toBe("true");
441
+ act(() => light?.click());
442
+ expect(light?.dataset.lit).toBe("false");
443
+ tick();
444
+ expect(light?.dataset.lit).toBe("false");
445
+ });
446
+
447
+ it("stays dark in the red band just under the ceiling", () => {
448
+ usePlayerStore.setState({ elements: [clip({})] });
449
+ setHook(hookAt(10 ** (-1.2 / 20)));
450
+ const { host } = mount();
451
+ tick();
452
+ expect(host.querySelector<HTMLElement>("[data-testid=meter-clip-light]")?.dataset.lit).toBe(
453
+ "false",
454
+ );
455
+ });
456
+ });
457
+
458
+ describe("stepClipLatch", () => {
459
+ it("lights exactly at -1 dBFS on either channel, not below", () => {
460
+ expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6);
461
+ expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true);
462
+ expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true);
463
+ expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false);
464
+ expect(stepClipLatch(false, undefined)).toBe(false);
465
+ });
466
+
467
+ it("holds once lit, whatever comes next", () => {
468
+ expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true);
469
+ expect(stepClipLatch(true, undefined)).toBe(true);
470
+ });
471
+ });
@@ -5,6 +5,7 @@ import {
5
5
  useMemo,
6
6
  useRef,
7
7
  type PointerEvent as ReactPointerEvent,
8
+ type ReactNode,
8
9
  type Ref,
9
10
  type RefObject,
10
11
  } from "react";
@@ -20,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
20
21
  import { usePlayerStore } from "../../player";
21
22
  import { clampNumber } from "../../utils/studioHelpers";
22
23
  import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
24
+ import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
23
25
  import { useStudioShellContextOptional } from "../../contexts/StudioContext";
24
26
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
25
27
  import {
@@ -49,6 +51,24 @@ type StripBars = [Bars, Bars];
49
51
 
50
52
  const MONITOR_LABEL = "Monitor";
51
53
 
54
+ /** The CLIP light latches at the export ceiling, stricter than RED_AT. */
55
+ export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING;
56
+
57
+ export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean {
58
+ if (latched || !master) return latched;
59
+ return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK;
60
+ }
61
+
62
+ function paintClipLight(el: HTMLElement | null, lit: boolean): void {
63
+ if (!el) return;
64
+ el.dataset.lit = String(lit);
65
+ el.style.setProperty(
66
+ "background-color",
67
+ lit ? "var(--color-red-500)" : "var(--color-neutral-800)",
68
+ );
69
+ el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)");
70
+ }
71
+
52
72
  /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
53
73
  const AMBER_AT = markFraction(-6);
54
74
  const RED_AT = markFraction(-3);
@@ -178,10 +198,16 @@ export function stepAndPaintStrips(
178
198
  }
179
199
 
180
200
  /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
201
+ interface ClipLatch {
202
+ lit: boolean;
203
+ light: HTMLElement | null;
204
+ }
205
+
181
206
  function useMeterLoop(
182
207
  strips: Strip[],
183
208
  bars: RefObject<Map<string | null, StripBars>>,
184
209
  iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
210
+ clip: RefObject<ClipLatch>,
185
211
  ) {
186
212
  const shell = useStudioShellContextOptional();
187
213
  const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
@@ -200,6 +226,11 @@ function useMeterLoop(
200
226
  last = now;
201
227
  evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
202
228
  stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
229
+ const lit = stepClipLatch(clip.current.lit, levels?.master);
230
+ if (lit !== clip.current.lit) {
231
+ clip.current.lit = lit;
232
+ paintClipLight(clip.current.light, lit);
233
+ }
203
234
  };
204
235
  raf = requestAnimationFrame(tick);
205
236
  return () => {
@@ -210,7 +241,29 @@ function useMeterLoop(
210
241
  // Preview iframe already gone.
211
242
  }
212
243
  };
213
- }, [previewIframeRef, bars]);
244
+ }, [previewIframeRef, bars, clip]);
245
+ }
246
+
247
+ function ClipLight({ clip }: { clip: RefObject<ClipLatch> }) {
248
+ return (
249
+ <button
250
+ type="button"
251
+ data-testid="meter-clip-light"
252
+ data-lit="false"
253
+ title="Lights when the preview reaches −1 dBFS, the export ceiling. Click to reset."
254
+ ref={(el) => {
255
+ clip.current.light = el;
256
+ paintClipLight(el, clip.current.lit);
257
+ }}
258
+ onClick={() => {
259
+ clip.current.lit = false;
260
+ paintClipLight(clip.current.light, false);
261
+ }}
262
+ className="rounded-[2px] px-1 font-mono text-[9px] leading-[14px] font-bold tracking-wide"
263
+ >
264
+ CLIP
265
+ </button>
266
+ );
214
267
  }
215
268
 
216
269
  function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
@@ -341,11 +394,13 @@ function MeterStrip({
341
394
  register,
342
395
  onLive,
343
396
  onCommit,
397
+ clipLight,
344
398
  }: {
345
399
  strip: Strip;
346
400
  register: (id: string | null, bars: StripBars | null) => void;
347
401
  onLive: (id: string | null, volume: number) => void;
348
402
  onCommit: (id: string | null, volume: number) => void;
403
+ clipLight?: ReactNode;
349
404
  }) {
350
405
  const refs = [
351
406
  useRef<HTMLDivElement>(null),
@@ -364,6 +419,7 @@ function MeterStrip({
364
419
  }, [strip.id, register]);
365
420
  return (
366
421
  <div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
422
+ {clipLight}
367
423
  <div
368
424
  className="flex min-h-0 flex-1 items-stretch gap-1.5"
369
425
  aria-label={`${strip.label} level`}
@@ -421,7 +477,8 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
421
477
  else bars.current.delete(id);
422
478
  }).current;
423
479
  const { onLive, onCommit } = useVolumeHandlers();
424
- useMeterLoop(strips, bars, previewIframeRef);
480
+ const clip = useRef<ClipLatch>({ lit: false, light: null });
481
+ useMeterLoop(strips, bars, previewIframeRef, clip);
425
482
  return (
426
483
  <div
427
484
  data-testid="audio-meter-strip"
@@ -434,6 +491,7 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
434
491
  register={register}
435
492
  onLive={onLive}
436
493
  onCommit={onCommit}
494
+ clipLight={strip.id === null ? <ClipLight clip={clip} /> : undefined}
437
495
  />
438
496
  ))}
439
497
  </div>
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
53
53
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
54
54
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55
55
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
56
+ handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
56
57
  /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
57
58
  handleGroupClips?: (
58
59
  clipIds: readonly string[],
@@ -132,6 +133,7 @@ export function useTimelineEditCallbacks({
132
133
  handleTimelineElementSplit,
133
134
  handleRazorSplit,
134
135
  handleRazorSplitAll,
136
+ handleFreezeFrame,
135
137
  handleGroupClips,
136
138
  setElementFxAttribute,
137
139
  }: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -227,6 +229,7 @@ export function useTimelineEditCallbacks({
227
229
  onSplitElement: handleTimelineElementSplit,
228
230
  onRazorSplit: handleRazorSplit,
229
231
  onRazorSplitAll: handleRazorSplitAll,
232
+ onFreezeFrame: handleFreezeFrame,
230
233
  onDeleteAllKeyframes: (element, animationId) => {
231
234
  // Hold the element where it is (collapse keyframes to a static set) rather
232
235
  // than deleting the whole animation — deleting strands a stale GSAP base
@@ -423,6 +426,7 @@ export function useTimelineEditCallbacks({
423
426
  handleTimelineElementSplit,
424
427
  handleRazorSplit,
425
428
  handleRazorSplitAll,
429
+ handleFreezeFrame,
426
430
  handleGsapRemoveAllKeyframes,
427
431
  resolveElementAnimations,
428
432
  resolveKeyframeTarget,
@@ -1,3 +1,5 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
4
  import {
3
5
  toggleMainLineSlide,
@@ -14,6 +16,7 @@ import {
14
16
  removeHotspot,
15
17
  safeParseManifest,
16
18
  makeSlideshowNotesController,
19
+ isPanelUndoKey,
17
20
  } from "./SlideshowPanel";
18
21
  import type { SlideshowManifest } from "@hyperframes/core/slideshow";
19
22
 
@@ -518,3 +521,27 @@ describe("makeSlideshowNotesController", () => {
518
521
  expect(persist).not.toHaveBeenCalled();
519
522
  });
520
523
  });
524
+
525
+ describe("isPanelUndoKey", () => {
526
+ function undoFrom(html: string) {
527
+ document.body.innerHTML = `<div>${html}</div>`;
528
+ const target = document.body.firstElementChild!.firstElementChild!;
529
+ return { metaKey: true, ctrlKey: false, shiftKey: false, key: "z", target };
530
+ }
531
+
532
+ it("undoes the panel from a button or a slider", () => {
533
+ expect(isPanelUndoKey(undoFrom("<button>Add</button>"))).toBe(true);
534
+ expect(isPanelUndoKey(undoFrom('<div role="slider" tabindex="0"></div>'))).toBe(true);
535
+ });
536
+
537
+ it("leaves Cmd+Z to a text field, contenteditable and select included", () => {
538
+ expect(isPanelUndoKey(undoFrom("<input />"))).toBe(false);
539
+ expect(isPanelUndoKey(undoFrom('<p contenteditable="plaintext-only">Note</p>'))).toBe(false);
540
+ expect(isPanelUndoKey(undoFrom("<select><option>a</option></select>"))).toBe(false);
541
+ });
542
+
543
+ it("ignores Cmd+Shift+Z and a bare z", () => {
544
+ expect(isPanelUndoKey({ ...undoFrom("<button />"), shiftKey: true })).toBe(false);
545
+ expect(isPanelUndoKey({ ...undoFrom("<button />"), metaKey: false })).toBe(false);
546
+ });
547
+ });
@@ -21,6 +21,7 @@ import { usePlayerStore } from "../../player";
21
21
  import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
22
22
  import { useFileManagerContext } from "../../contexts/FileManagerContext";
23
23
  import { generateId } from "../../utils/generateId";
24
+ import { isTypingTarget } from "../../utils/typingTarget";
24
25
  import {
25
26
  SectionHeader,
26
27
  SlideList,
@@ -153,6 +154,13 @@ export function makeSlideshowNotesController(): NotesController {
153
154
  };
154
155
  }
155
156
 
157
+ export function isPanelUndoKey(
158
+ e: Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "shiftKey" | "key" | "target">,
159
+ ): boolean {
160
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.key.toLowerCase() !== "z") return false;
161
+ return !isTypingTarget(e.target);
162
+ }
163
+
156
164
  // ── Component ─────────────────────────────────────────────────────────────
157
165
 
158
166
  export interface SlideshowPanelProps {
@@ -416,13 +424,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
416
424
  className="flex flex-col h-full overflow-y-auto text-text-0"
417
425
  onKeyDown={(e) => {
418
426
  // In-panel undo — scoped so it never fights the app-level file undo.
419
- if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
420
- const target = e.target instanceof HTMLElement ? e.target.tagName : "";
421
- if (target === "TEXTAREA" || target === "INPUT") return;
422
- e.preventDefault();
423
- e.stopPropagation();
424
- handleUndo();
425
- }
427
+ if (!isPanelUndoKey(e)) return;
428
+ e.preventDefault();
429
+ e.stopPropagation();
430
+ handleUndo();
426
431
  }}
427
432
  >
428
433
  {persistError && (
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
6
+ import { DomEditProvider, useDomEditActionsContext } from "./DomEditContext";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const cleanup: Array<() => void> = [];
11
+ afterEach(() => cleanup.splice(0).forEach((step) => step()));
12
+
13
+ /** A resize whose save is still running, committed through the actions the canvas and panels use. */
14
+ function commitResize(save: Promise<void>) {
15
+ const handleDomBoxSizeCommit = vi.fn(() => save);
16
+ const value = { handleDomBoxSizeCommit } as unknown as Parameters<
17
+ typeof DomEditProvider
18
+ >[0]["value"];
19
+ let actions!: ReturnType<typeof useDomEditActionsContext>;
20
+ function Canvas() {
21
+ actions = useDomEditActionsContext();
22
+ return null;
23
+ }
24
+ const root = createRoot(document.createElement("div"));
25
+ act(() =>
26
+ root.render(
27
+ <DomEditProvider value={value}>
28
+ <Canvas />
29
+ </DomEditProvider>,
30
+ ),
31
+ );
32
+ cleanup.push(() => act(() => root.unmount()));
33
+ const committed = actions.handleDomBoxSizeCommit(
34
+ {} as never,
35
+ { width: 300, height: 200 },
36
+ undefined,
37
+ );
38
+ return { committed, handleDomBoxSizeCommit };
39
+ }
40
+
41
+ it("undo waits for an edit committed before its save has written anything", async () => {
42
+ let finish!: () => void;
43
+ const { committed } = commitResize(new Promise<void>((resolve) => (finish = resolve)));
44
+ let drained = false;
45
+ const drain = flushStudioPendingEdits().then(() => (drained = true));
46
+ await new Promise((resolve) => setTimeout(resolve, 20));
47
+ expect(drained).toBe(false);
48
+ finish();
49
+ await drain;
50
+ await committed;
51
+ expect(drained).toBe(true);
52
+ });
53
+
54
+ it("an edit that fails is waited out and reported to the drain, like every tracked edit", async () => {
55
+ let fail!: (error: Error) => void;
56
+ const { committed } = commitResize(new Promise<void>((_, reject) => (fail = reject)));
57
+ const drain = flushStudioPendingEdits();
58
+ const failure = new Error("blocked");
59
+ fail(failure);
60
+ await expect(committed).rejects.toThrow("blocked");
61
+ await expect(drain).resolves.toEqual({ status: "failed", error: failure });
62
+ });
@@ -2,85 +2,88 @@
2
2
  import type { useDomEditSession } from "../hooks/useDomEditSession";
3
3
  import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
4
4
  import { createStableContext } from "../utils/hmrStableContext";
5
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
5
6
 
6
7
  type DomEditValue = ReturnType<typeof useDomEditSession>;
7
8
 
8
- export interface DomEditActionsValue extends Pick<
9
- DomEditValue,
10
- | "handleTimelineElementSelect"
11
- | "handlePreviewCanvasMouseDown"
12
- | "handlePreviewCanvasPointerMove"
13
- | "handlePreviewCanvasPointerLeave"
14
- | "applyDomSelection"
15
- | "clearDomSelection"
16
- | "refreshDomEditSelectionFromPreview"
17
- | "handleDomStyleCommit"
18
- | "handleDomStyleCommitForSelection"
19
- | "handleDomAttributeCommit"
20
- | "handleDomAttributeLiveCommit"
21
- | "handleDomAttributeQuietCommit"
22
- | "handleDomHtmlAttributeCommit"
23
- | "handleDomAttributesCommit"
24
- | "handleDomPathOffsetCommit"
25
- | "handleDomGroupPathOffsetCommit"
26
- | "handleDomZIndexReorderCommit"
27
- | "handleDomBoxSizeCommit"
28
- | "handleDomRotationCommit"
29
- | "handleDomManualEditsReset"
30
- | "handleDomTextCommit"
31
- | "handleDomTextCommitForSelection"
32
- | "handleDomRichTextCommit"
33
- | "handleDomTextFieldStyleCommit"
34
- | "handleDomAddTextField"
35
- | "handleDomRemoveTextField"
36
- | "getGsapAnimationsForSelection"
37
- | "handleAskAgent"
38
- | "handleAgentModalSubmit"
39
- | "handleBlockedDomMove"
40
- | "handleDomManualDragStart"
41
- | "handleDomEditElementDelete"
42
- | "handleGroupSelection"
43
- | "handleUngroupSelection"
44
- | "setActiveGroupElement"
45
- | "buildDomSelectionFromTarget"
46
- | "buildDomSelectionForTimelineElement"
47
- | "updateDomEditHoverSelection"
48
- | "resolveImportedFontAsset"
49
- | "setAgentModalOpen"
50
- | "setAgentPromptSelectionContext"
51
- | "setAgentModalAnchorPoint"
52
- | "handleGsapUpdateProperty"
53
- | "handleGsapUpdateMeta"
54
- | "handleGsapDeleteAnimation"
55
- | "handleGsapDeleteAllForElement"
56
- | "handleGsapAddAnimation"
57
- | "handleGsapAddProperty"
58
- | "handleGsapRemoveProperty"
59
- | "handleGsapUpdateFromProperty"
60
- | "handleGsapAddFromProperty"
61
- | "handleGsapRemoveFromProperty"
62
- | "handleGsapAddKeyframe"
63
- | "handleGsapAddKeyframeBatch"
64
- | "handleGsapRemoveKeyframe"
65
- | "handleGsapMoveKeyframeToPlayhead"
66
- | "handleGsapMoveKeyframe"
67
- | "handleGsapResizeKeyframedTween"
68
- | "handleGsapConvertToKeyframes"
69
- | "handleGsapRemoveAllKeyframes"
70
- | "handleResetSelectedElementKeyframes"
71
- | "commitAnimatedProperty"
72
- | "commitAnimatedProperties"
73
- | "handleSetArcPath"
74
- | "handleUpdateArcSegment"
75
- | "handleUnroll"
76
- | "invalidateGsapCache"
77
- | "previewIframeRef"
78
- | "commitMutation"
79
- | "applyMarqueeSelection"
80
- | "handleUpdateKeyframeEase"
81
- | "handleUpdateSegmentEase"
82
- | "handleSetAllKeyframeEases"
83
- > {}
9
+ const SAVES_AN_EDIT = {
10
+ handleTimelineElementSelect: false,
11
+ handlePreviewCanvasMouseDown: false,
12
+ handlePreviewCanvasPointerMove: false,
13
+ handlePreviewCanvasPointerLeave: false,
14
+ applyDomSelection: false,
15
+ clearDomSelection: false,
16
+ refreshDomEditSelectionFromPreview: false,
17
+ handleDomStyleCommit: true,
18
+ handleDomStyleCommitForSelection: true,
19
+ handleDomAttributeCommit: true,
20
+ handleDomAttributeLiveCommit: false,
21
+ handleDomAttributeQuietCommit: true,
22
+ handleDomHtmlAttributeCommit: true,
23
+ handleDomAttributesCommit: true,
24
+ handleDomAttributeBatchCommit: true,
25
+ handleDomPathOffsetCommit: true,
26
+ handleDomGroupPathOffsetCommit: true,
27
+ handleDomZIndexReorderCommit: true,
28
+ handleDomBoxSizeCommit: true,
29
+ handleDomRotationCommit: true,
30
+ handleDomManualEditsReset: true,
31
+ handleDomTextCommit: true,
32
+ handleDomTextCommitForSelection: true,
33
+ handleDomRichTextCommit: true,
34
+ handleDomTextFieldStyleCommit: true,
35
+ handleDomAddTextField: true,
36
+ handleDomRemoveTextField: true,
37
+ getGsapAnimationsForSelection: false,
38
+ handleAskAgent: false,
39
+ handleAgentModalSubmit: false,
40
+ handleBlockedDomMove: false,
41
+ handleDomManualDragStart: false,
42
+ handleDomEditElementDelete: true,
43
+ handleGroupSelection: true,
44
+ handleUngroupSelection: true,
45
+ setActiveGroupElement: false,
46
+ buildDomSelectionFromTarget: false,
47
+ buildDomSelectionForTimelineElement: false,
48
+ updateDomEditHoverSelection: false,
49
+ resolveImportedFontAsset: false,
50
+ setAgentModalOpen: false,
51
+ setAgentPromptSelectionContext: false,
52
+ setAgentModalAnchorPoint: false,
53
+ handleGsapUpdateProperty: true,
54
+ handleGsapUpdateMeta: true,
55
+ handleGsapDeleteAnimation: true,
56
+ handleGsapDeleteAllForElement: true,
57
+ handleGsapAddAnimation: true,
58
+ handleGsapAddProperty: true,
59
+ handleGsapRemoveProperty: true,
60
+ handleGsapUpdateFromProperty: true,
61
+ handleGsapAddFromProperty: true,
62
+ handleGsapRemoveFromProperty: true,
63
+ handleGsapAddKeyframe: true,
64
+ handleGsapAddKeyframeBatch: true,
65
+ handleGsapRemoveKeyframe: true,
66
+ handleGsapMoveKeyframeToPlayhead: true,
67
+ handleGsapMoveKeyframe: true,
68
+ handleGsapResizeKeyframedTween: true,
69
+ handleGsapConvertToKeyframes: true,
70
+ handleGsapRemoveAllKeyframes: true,
71
+ handleResetSelectedElementKeyframes: true,
72
+ commitAnimatedProperty: true,
73
+ commitAnimatedProperties: true,
74
+ handleSetArcPath: true,
75
+ handleUpdateArcSegment: true,
76
+ handleUnroll: true,
77
+ invalidateGsapCache: false,
78
+ previewIframeRef: false,
79
+ commitMutation: true,
80
+ applyMarqueeSelection: false,
81
+ handleUpdateKeyframeEase: true,
82
+ handleUpdateSegmentEase: true,
83
+ handleSetAllKeyframeEases: true,
84
+ } as const satisfies Partial<Record<keyof DomEditValue, boolean>>;
85
+
86
+ export interface DomEditActionsValue extends Pick<DomEditValue, keyof typeof SAVES_AN_EDIT> {}
84
87
 
85
88
  export interface DomEditSelectionValue extends Pick<
86
89
  DomEditValue,
@@ -98,6 +101,15 @@ export interface DomEditSelectionValue extends Pick<
98
101
  | "agentPromptSelectionContext"
99
102
  > {}
100
103
 
104
+ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
105
+ const tracked: Record<string, unknown> = { ...actions };
106
+ for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
107
+ if (!SAVES_AN_EDIT[key]) continue;
108
+ tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
109
+ }
110
+ return tracked as unknown as DomEditActionsValue;
111
+ }
112
+
101
113
  const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
102
114
  "DomEditActionsContext",
103
115
  null,
@@ -164,6 +176,7 @@ export function DomEditProvider({
164
176
  handleDomAttributeQuietCommit,
165
177
  handleDomHtmlAttributeCommit,
166
178
  handleDomAttributesCommit,
179
+ handleDomAttributeBatchCommit,
167
180
  handleDomPathOffsetCommit,
168
181
  handleDomGroupPathOffsetCommit,
169
182
  handleDomZIndexReorderCommit,
@@ -242,7 +255,7 @@ export function DomEditProvider({
242
255
  [],
243
256
  );
244
257
 
245
- const actions = useMemo<DomEditActionsValue>(
258
+ const untrackedActions = useMemo<DomEditActionsValue>(
246
259
  () => ({
247
260
  handleTimelineElementSelect,
248
261
  handlePreviewCanvasMouseDown,
@@ -258,6 +271,7 @@ export function DomEditProvider({
258
271
  handleDomAttributeQuietCommit,
259
272
  handleDomHtmlAttributeCommit,
260
273
  handleDomAttributesCommit,
274
+ handleDomAttributeBatchCommit,
261
275
  handleDomPathOffsetCommit,
262
276
  handleDomGroupPathOffsetCommit,
263
277
  handleDomZIndexReorderCommit,
@@ -333,6 +347,7 @@ export function DomEditProvider({
333
347
  handleDomAttributeQuietCommit,
334
348
  handleDomHtmlAttributeCommit,
335
349
  handleDomAttributesCommit,
350
+ handleDomAttributeBatchCommit,
336
351
  handleDomPathOffsetCommit,
337
352
  handleDomGroupPathOffsetCommit,
338
353
  handleDomZIndexReorderCommit,
@@ -395,6 +410,8 @@ export function DomEditProvider({
395
410
  ],
396
411
  );
397
412
 
413
+ const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]);
414
+
398
415
  const selection = useMemo<DomEditSelectionValue>(
399
416
  () => ({
400
417
  domEditSelection,
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
42
42
  onSplitElement: true,
43
43
  onRazorSplit: true,
44
44
  onRazorSplitAll: true,
45
+ onFreezeFrame: true,
45
46
  onDeleteKeyframe: true,
46
47
  onDeleteAllKeyframes: true,
47
48
  onMoveKeyframeToPlayhead: true,