@hyperframes/studio 0.8.106 → 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 (110) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
  3. package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
  5. package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
  6. package/dist/chunk-4OZC426H.js.map +1 -0
  7. package/dist/index.d.ts +367 -11
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1064 -714
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +5 -3
  14. package/src/agentToolsExport.test.ts +8 -0
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/components/StudioRightPanels.tsx +2 -0
  17. package/src/components/TimelineToolbar.test.tsx +27 -0
  18. package/src/components/TimelineToolbar.tsx +2 -5
  19. package/src/components/editor/DomEditOverlay.tsx +12 -18
  20. package/src/components/editor/PropertyPanel.tsx +1 -14
  21. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  22. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  23. package/src/components/editor/SnapToolbar.tsx +3 -4
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  25. package/src/components/editor/fontAssets.test.ts +12 -22
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  27. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  28. package/src/components/editor/manualEdits.test.ts +4 -3
  29. package/src/components/editor/manualEditsDom.ts +28 -5
  30. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  31. package/src/components/editor/manualOffsetDrag.ts +4 -1
  32. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  33. package/src/components/editor/mediaAudioEdits.ts +253 -0
  34. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  36. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  37. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  40. package/src/components/editor/useDomEditNudge.ts +41 -23
  41. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  42. package/src/components/panels/SlideshowPanel.tsx +12 -7
  43. package/src/contexts/DomEditContext.test.tsx +62 -0
  44. package/src/contexts/DomEditContext.tsx +94 -77
  45. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  46. package/src/hooks/domEditCommitTypes.ts +10 -0
  47. package/src/hooks/domEditPersistFailure.ts +12 -1
  48. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  49. package/src/hooks/domStyleCommit.ts +2 -8
  50. package/src/hooks/gsapDragCommit.ts +3 -8
  51. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  52. package/src/hooks/gsapEditOutcome.ts +2 -1
  53. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  55. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  56. package/src/hooks/gsapResizeIntercept.ts +6 -2
  57. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  58. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  59. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  60. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  61. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  62. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  63. package/src/hooks/useAppHotkeys.test.ts +17 -0
  64. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  65. package/src/hooks/useAppHotkeys.ts +3 -3
  66. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  67. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  68. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  69. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  70. package/src/hooks/useDomEditCommits.ts +2 -0
  71. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  72. package/src/hooks/useDomEditPersist.ts +28 -13
  73. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  74. package/src/hooks/useDomEditSession.ts +2 -3
  75. package/src/hooks/useDomEditTextCommits.ts +4 -8
  76. package/src/hooks/useDomGeometryCommits.ts +11 -2
  77. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  78. package/src/hooks/useGsapAwareEditing.ts +6 -6
  79. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  80. package/src/hooks/useGsapScriptCommits.ts +5 -4
  81. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  82. package/src/hooks/useInlineTextEdit.ts +9 -7
  83. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  84. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  85. package/src/hooks/usePreviewPersistence.ts +6 -0
  86. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  87. package/src/index.ts +7 -0
  88. package/src/player/components/Timeline.test.ts +0 -21
  89. package/src/player/components/Timeline.tsx +0 -1
  90. package/src/player/components/timelineLayout.ts +0 -28
  91. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  92. package/src/player/lib/playbackShortcuts.ts +3 -7
  93. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  94. package/src/player/lib/timelineElementHelpers.ts +16 -2
  95. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  96. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  97. package/src/utils/gsapSoftReload.ts +3 -2
  98. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  99. package/src/utils/gsapUndoRestore.test.ts +145 -1
  100. package/src/utils/gsapUndoRestore.ts +70 -21
  101. package/src/utils/sourcePatcher.ts +1 -1
  102. package/src/utils/studioFontHelpers.ts +2 -23
  103. package/src/utils/studioHelpers.test.ts +30 -0
  104. package/src/utils/studioHelpers.ts +18 -4
  105. package/src/utils/studioPendingEdits.test.ts +63 -0
  106. package/src/utils/studioPendingEdits.ts +16 -7
  107. package/src/utils/typingTarget.test.ts +13 -0
  108. package/src/utils/typingTarget.ts +5 -2
  109. package/src/webmcp/useStudioAgentTools.ts +3 -1
  110. package/dist/chunk-ICTZJPQQ.js.map +0 -1
@@ -3,6 +3,7 @@ import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-pa
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
5
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
6
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
6
7
 
7
8
  export interface BackgroundRemovalProgress {
8
9
  status: "processing" | "complete" | "failed";
@@ -68,6 +69,7 @@ export interface PropertyPanelProps {
68
69
  value: string | null,
69
70
  ) => Promise<{ changedFiles: number; changedElements: number }>;
70
71
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
72
+ onSetAttributeBatch: CommitDomAttributeBatch;
71
73
  onRemoveBackground?: (
72
74
  inputPath: string,
73
75
  options: {
@@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
6
6
  import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
7
7
  import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
8
8
  import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
9
10
  import type { DomEditSelection } from "./domEditing";
10
11
  import type { OverlayRect } from "./domEditOverlayGeometry";
11
12
 
@@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
27
28
  // Stable across renders on purpose: the test targets the `selection` identity
28
29
  // key specifically, so `groupSelections` must not itself be a source of churn.
29
30
  const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
31
+ let flushNudge = () => {};
30
32
 
31
33
  function Harness({
32
34
  selection,
@@ -35,7 +37,7 @@ function Harness({
35
37
  selection: DomEditSelection | null;
36
38
  onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
37
39
  }) {
38
- useDomEditNudge({
40
+ flushNudge = useDomEditNudge({
39
41
  selection,
40
42
  groupSelections: EMPTY_GROUP_SELECTIONS,
41
43
  allowCanvasMovement: true,
@@ -49,7 +51,7 @@ function Harness({
49
51
  onBlockedMoveRef: makeRef(() => {}),
50
52
  onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
51
53
  onGroupPathOffsetCommitRef: makeRef(async () => {}),
52
- });
54
+ }).flushNudge;
53
55
  return null;
54
56
  }
55
57
 
@@ -259,6 +261,51 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
259
261
  });
260
262
  });
261
263
 
264
+ describe("useDomEditNudge — a focused native player owns the arrow keys", () => {
265
+ beforeEach(() => {
266
+ vi.useFakeTimers();
267
+ __resetForTests();
268
+ });
269
+ let root: ReturnType<typeof createRoot> | null = null;
270
+ afterEach(() => {
271
+ act(() => root?.unmount());
272
+ root = null;
273
+ document.body.innerHTML = "";
274
+ vi.useRealTimers();
275
+ });
276
+
277
+ it.each(["video", "audio"])(
278
+ "does not nudge the selection while a <%s controls> has focus",
279
+ (tag) => {
280
+ root = createRoot(document.body.appendChild(document.createElement("div")));
281
+ const element = document.body.appendChild(document.createElement("div"));
282
+ element.id = "dot";
283
+ const player = document.body.appendChild(document.createElement(tag));
284
+ player.setAttribute("controls", "");
285
+ const commit = vi.fn();
286
+ act(() => {
287
+ root?.render(
288
+ React.createElement(Harness, {
289
+ selection: makeSelection("Dot", element),
290
+ onPathOffsetCommit: commit,
291
+ }),
292
+ );
293
+ });
294
+ const event = new KeyboardEvent("keydown", {
295
+ key: "ArrowRight",
296
+ bubbles: true,
297
+ cancelable: true,
298
+ });
299
+ act(() => {
300
+ player.dispatchEvent(event);
301
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
302
+ });
303
+ expect(event.defaultPrevented).toBe(false);
304
+ expect(commit).not.toHaveBeenCalled();
305
+ },
306
+ );
307
+ });
308
+
262
309
  describe("useDomEditNudge carries the route its press chose", () => {
263
310
  beforeEach(() => {
264
311
  vi.useFakeTimers();
@@ -291,3 +338,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
291
338
  element.remove();
292
339
  });
293
340
  });
341
+
342
+ describe("useDomEditNudge — undo right after a burst", () => {
343
+ it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
344
+ __resetForTests();
345
+ vi.useFakeTimers();
346
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
347
+ const element = document.body.appendChild(document.createElement("div"));
348
+ element.id = "dot-undo";
349
+ let saved!: () => void;
350
+ const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
351
+ act(() => {
352
+ root.render(
353
+ React.createElement(Harness, {
354
+ selection: makeSelection("Dot", element),
355
+ onPathOffsetCommit: commit,
356
+ }),
357
+ );
358
+ });
359
+ act(() => dispatchArrowRight());
360
+
361
+ let drained = false;
362
+ const drain = flushStudioPendingEdits().then(() => (drained = true));
363
+ expect(commit).toHaveBeenCalledTimes(1);
364
+ vi.useRealTimers();
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ expect(drained).toBe(false);
367
+ saved();
368
+ await drain;
369
+ expect(drained).toBe(true);
370
+ act(() => root.unmount());
371
+ });
372
+
373
+ it("counts the burst as a pending edit from its first key until its save lands", async () => {
374
+ __resetForTests();
375
+ vi.useFakeTimers();
376
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
377
+ const element = document.body.appendChild(document.createElement("div"));
378
+ element.id = "dot-pending";
379
+ let saved!: () => void;
380
+ const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
381
+ act(() => {
382
+ root.render(
383
+ React.createElement(Harness, {
384
+ selection: makeSelection("Dot", element),
385
+ onPathOffsetCommit: commit,
386
+ }),
387
+ );
388
+ });
389
+ expect(hasStudioPendingEdits()).toBe(false);
390
+ act(() => dispatchArrowRight());
391
+ expect(hasStudioPendingEdits()).toBe(true);
392
+ act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
393
+ expect(commit).toHaveBeenCalledTimes(1);
394
+ expect(hasStudioPendingEdits()).toBe(true);
395
+ vi.useRealTimers();
396
+ saved();
397
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
398
+ act(() => root.unmount());
399
+ });
400
+ });
401
+
402
+ describe("useDomEditNudge — a commit that throws", () => {
403
+ it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
404
+ __resetForTests();
405
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
406
+ const element = document.body.appendChild(document.createElement("div"));
407
+ element.id = "dot-throws";
408
+ const commit = vi.fn(() => {
409
+ throw new Error("The commit threw.");
410
+ });
411
+ act(() => {
412
+ root.render(
413
+ React.createElement(Harness, {
414
+ selection: makeSelection("Dot", element),
415
+ onPathOffsetCommit: commit,
416
+ }),
417
+ );
418
+ });
419
+ act(() => dispatchArrowRight());
420
+ expect(hasStudioPendingEdits()).toBe(true);
421
+ expect(() => flushNudge()).toThrow("The commit threw.");
422
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
423
+ act(() => root.unmount());
424
+ });
425
+ });
@@ -9,6 +9,10 @@ import { useCallback, useEffect, useRef, type RefObject } from "react";
9
9
  import { useMountEffect } from "../../hooks/useMountEffect";
10
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,7 +132,7 @@ 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;
@@ -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
@@ -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
+ });