@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
@@ -7,8 +7,12 @@
7
7
  */
8
8
  import { useCallback, useEffect, useRef, type RefObject } from "react";
9
9
  import { useMountEffect } from "../../hooks/useMountEffect";
10
- import { isTypingTarget } from "../../utils/typingTarget";
10
+ import { ownsPlainKeys } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
+ import {
13
+ addStudioPendingEditFlushListener,
14
+ trackStudioPendingEdit,
15
+ } from "../../utils/studioPendingEdits";
12
16
  import type { DomEditSelection } from "./domEditing";
13
17
  import {
14
18
  type GroupOverlayItem,
@@ -43,6 +47,7 @@ interface NudgeSession {
43
47
  /** Accumulated delta of the burst, in composition px. */
44
48
  accum: { x: number; y: number };
45
49
  timer: ReturnType<typeof setTimeout> | null;
50
+ endPendingEdit: (saved?: Promise<unknown>) => void;
46
51
  }
47
52
 
48
53
  export interface UseDomEditNudgeParams {
@@ -127,12 +132,12 @@ function resolveSingleNudgeTarget(
127
132
 
128
133
  /**
129
134
  * True when a keydown must not start/extend a nudge: canvas movement disabled,
130
- * a pointer gesture already owns the element, or the user is typing in a field.
135
+ * a pointer gesture already owns the element, or focus is in a field or a native player.
131
136
  */
132
137
  function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
133
138
  if (!p.allowCanvasMovement || event.defaultPrevented) return true;
134
139
  if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
135
- return isTypingTarget(event.target);
140
+ return ownsPlainKeys(event.target);
136
141
  }
137
142
 
138
143
  export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
@@ -143,31 +148,37 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
143
148
  // Commit the pending burst: one source write per burst = one undo entry.
144
149
  // Mirrors the drag's onPointerUp — same commit callbacks, same failure
145
150
  // restore, same member teardown.
146
- const commitSession = () => {
151
+ const commitSession = (): Promise<unknown> | undefined => {
147
152
  const session = sessionRef.current;
148
- if (!session) return;
153
+ if (!session) return undefined;
149
154
  sessionRef.current = null;
150
155
  if (session.timer) clearTimeout(session.timer);
151
- const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
152
- selection: member.selection,
153
- next: applyManualOffsetNudgeCommit(member, session.accum),
154
- plainTranslate: member.plainTranslate,
155
- }));
156
156
  const p = paramsRef.current;
157
- const commit = session.isGroup
158
- ? p.onGroupPathOffsetCommitRef.current(updates)
159
- : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
160
- plainTranslate: updates[0].plainTranslate,
161
- });
162
- void Promise.resolve(commit)
163
- .catch(() => {
164
- for (const member of session.members) {
165
- if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
166
- restoreStudioPathOffset(member.element, member.initialPathOffset);
157
+ let saved: Promise<unknown> | undefined;
158
+ try {
159
+ const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
160
+ selection: member.selection,
161
+ next: applyManualOffsetNudgeCommit(member, session.accum),
162
+ plainTranslate: member.plainTranslate,
163
+ }));
164
+ const commit = session.isGroup
165
+ ? p.onGroupPathOffsetCommitRef.current(updates)
166
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
167
+ plainTranslate: updates[0].plainTranslate,
168
+ });
169
+ saved = Promise.resolve(commit)
170
+ .catch(() => {
171
+ for (const member of session.members) {
172
+ if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
173
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
174
+ }
167
175
  }
168
- }
169
- })
170
- .finally(() => endManualOffsetDragMembers(session.members));
176
+ })
177
+ .finally(() => endManualOffsetDragMembers(session.members));
178
+ return saved;
179
+ } finally {
180
+ session.endPendingEdit(saved);
181
+ }
171
182
  };
172
183
  const commitSessionRef = useRef(commitSession);
173
184
  commitSessionRef.current = commitSession;
@@ -179,6 +190,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
179
190
  if (session.timer) clearTimeout(session.timer);
180
191
  restoreManualOffsetDragMembers(session.members);
181
192
  endManualOffsetDragMembers(session.members);
193
+ session.endPendingEdit();
182
194
  }, [params.allowCanvasMovement]);
183
195
 
184
196
  // Build drag members for the current target set — the same member snapshot a
@@ -205,7 +217,9 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
205
217
  if (members.length === 0) return null;
206
218
  // Same side effect a drag start has (pauses preview playback).
207
219
  p.onManualDragStartRef.current?.();
208
- return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
220
+ let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
221
+ trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
222
+ return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
209
223
  };
210
224
 
211
225
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -231,8 +245,12 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
231
245
  // Capture, like the other app-level key handlers, so a focused panel
232
246
  // can't swallow the nudge before it reaches us.
233
247
  window.addEventListener("keydown", listener, true);
248
+ const stopCommitOnUndoDrain = addStudioPendingEditFlushListener(() =>
249
+ commitSessionRef.current(),
250
+ );
234
251
  return () => {
235
252
  window.removeEventListener("keydown", listener, true);
253
+ stopCommitOnUndoDrain();
236
254
  commitSessionRef.current();
237
255
  };
238
256
  });
@@ -245,7 +263,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
245
263
  const selectionKey = selectionIdentityKey(params.selection);
246
264
  const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
247
265
  // eslint-disable-next-line no-restricted-syntax
248
- useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
266
+ useEffect(() => () => void commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
249
267
 
250
268
  // Claim the arrow keys from the playback frame-step while the selection is
251
269
  // nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
@@ -44,7 +44,7 @@ export function useInlineTextEditing(
44
44
  } {
45
45
  const actions = useDomEditActionsContextOptional();
46
46
  const inlineText = useInlineTextEdit({
47
- onCommit: (commit) => void actions?.handleDomRichTextCommit(commit),
47
+ onCommit: (commit) => actions?.handleDomRichTextCommit(commit),
48
48
  onPause: () => usePlayerStore.getState().setIsPlaying(false),
49
49
  });
50
50
  const lastPressRef = useRef<PressMark | null>(null);
@@ -0,0 +1,64 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+ import { useMotionPathData } from "./useMotionPathData";
6
+ import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
7
+ import { usePlayerStore } from "../../player/store/playerStore";
8
+
9
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
+
11
+ const originalRaf = window.requestAnimationFrame;
12
+ let frames: Array<() => void> = [];
13
+ const runFrames = (count: number) => {
14
+ for (let i = 0; i < count; i++) {
15
+ const batch = frames;
16
+ frames = [];
17
+ act(() => {
18
+ for (const frame of batch) frame();
19
+ vi.advanceTimersByTime(16);
20
+ });
21
+ }
22
+ };
23
+
24
+ beforeEach(() => {
25
+ vi.useFakeTimers({
26
+ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"],
27
+ });
28
+ window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
29
+ frames.push(() => callback(performance.now()))) as typeof window.requestAnimationFrame;
30
+ usePlayerStore.setState({ previewBooted: true, motionPathArmed: false });
31
+ });
32
+
33
+ afterEach(() => {
34
+ resetOverlayFrameLoopForTests();
35
+ window.requestAnimationFrame = originalRaf;
36
+ vi.useRealTimers();
37
+ usePlayerStore.setState({ previewBooted: false, motionPathArmed: false });
38
+ document.body.innerHTML = "";
39
+ });
40
+
41
+ it("reads no layout per frame until there is a path or a create ring to draw", () => {
42
+ const iframe = document.createElement("iframe");
43
+ document.body.append(iframe);
44
+ const reads = vi.spyOn(iframe, "getBoundingClientRect");
45
+ function Probe() {
46
+ const ref = useRef(iframe);
47
+ useMotionPathData(ref, "#box");
48
+ return null;
49
+ }
50
+ const host = document.createElement("div");
51
+ document.body.append(host);
52
+ const root = createRoot(host);
53
+ try {
54
+ act(() => root.render(<Probe />));
55
+ runFrames(10);
56
+ expect(reads).not.toHaveBeenCalled();
57
+
58
+ act(() => usePlayerStore.setState({ motionPathArmed: true }));
59
+ runFrames(10);
60
+ expect(reads).toHaveBeenCalled();
61
+ } finally {
62
+ act(() => root.unmount());
63
+ }
64
+ });
@@ -3,6 +3,7 @@ import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
3
3
  import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
4
4
  import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
5
5
  import { subscribeOverlayFrame } from "./overlayFrameLoop";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
6
7
 
7
8
  type Rect = { left: number; top: number; width: number; height: number };
8
9
 
@@ -114,6 +115,8 @@ export function useMotionPathData(
114
115
  // Perspective magnification (1/m44) of the selected element — applied to the
115
116
  // path's offset points so depth (translateZ) elements' paths track on screen.
116
117
  const [pScale, setPScale] = useState(1);
118
+ const armed = usePlayerStore((s) => s.motionPathArmed);
119
+ const drawn = geometry !== null || armed;
117
120
 
118
121
  useEffect(() => {
119
122
  if (!selector) {
@@ -122,6 +125,7 @@ export function useMotionPathData(
122
125
  return;
123
126
  }
124
127
  setHome(null);
128
+ if (!drawn) return;
125
129
  const tick = () => {
126
130
  const el = iframeRef.current;
127
131
  if (el) {
@@ -155,7 +159,7 @@ export function useMotionPathData(
155
159
  }
156
160
  };
157
161
  return subscribeOverlayFrame(tick);
158
- }, [selector, iframeRef]);
162
+ }, [selector, iframeRef, drawn]);
159
163
 
160
164
  useEffect(() => {
161
165
  if (!selector) {
@@ -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,
@@ -3,7 +3,7 @@ import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
6
- import { isTypingTarget } from "../utils/typingTarget";
6
+ import { isTypingTarget, ownsPlainKeys } from "../utils/typingTarget";
7
7
  import { isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
@@ -153,6 +153,7 @@ export function dispatchModifierKey(
153
153
  * Delete arbitration between keyframes, an automation range and the clip can
154
154
  * be asserted without standing up the whole hook. */
155
155
  export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
156
+ if (ownsPlainKeys(event.target)) return;
156
157
  if (key === STUDIO_PLAIN_KEYS.fullscreen && !event.shiftKey && !event.altKey) {
157
158
  event.preventDefault();
158
159
  if (document.fullscreenElement) void document.exitFullscreen();