@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
@@ -24,6 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
24
24
  import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
26
  import type { DomEditTimelineParams } from "./useDomSelectionTypes";
27
+ import { useLivePreviewIframe } from "./useLivePreviewIframe";
27
28
  // Re-exported: the delete rule lives in its own module; callers and its test import it from here.
28
29
  export { membersForDelete };
29
30
 
@@ -86,14 +87,13 @@ export function useDomEditSession({
86
87
  importedFontAssetsRef,
87
88
  projectDir,
88
89
  projectIdRef,
89
- previewIframe,
90
+ previewIframe: hostPreviewIframe,
90
91
  refreshKey,
91
92
  previewDocumentVersion,
92
93
  rightPanelTab,
93
94
  applyStudioManualEditsToPreviewRef,
94
95
  syncPreviewHotkeys,
95
96
  reloadPreview,
96
- setRefreshKey: _setRefreshKey,
97
97
  openSourceForSelection,
98
98
  sdkSession,
99
99
  publishSdkSession,
@@ -103,7 +103,7 @@ export function useDomEditSession({
103
103
  }: UseDomEditSessionParams) {
104
104
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
105
105
  const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
106
- void _setRefreshKey;
106
+ const previewIframe = useLivePreviewIframe(hostPreviewIframe);
107
107
  const {
108
108
  domEditSelection,
109
109
  domEditGroupSelections,
@@ -170,32 +170,13 @@ export function useDomEditSession({
170
170
  refreshDomEditSelectionFromPreview,
171
171
  });
172
172
  // ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
173
-
174
173
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
175
174
 
176
- // ── GSAP script commits ──
177
-
178
175
  const {
179
176
  commitMutation: gsapCommitMutation,
180
- updateGsapProperty,
181
- updateGsapMeta,
182
- deleteGsapAnimation,
183
- deleteAllForSelector,
184
- addGsapAnimation,
185
- addGsapProperty,
186
- removeGsapProperty,
187
- updateGsapFromProperty,
188
- addGsapFromProperty,
189
- removeGsapFromProperty,
190
- addKeyframe,
191
- addKeyframeBatch,
192
- removeKeyframe,
193
- moveKeyframe,
194
- resizeKeyframedTween,
195
- convertToKeyframes,
196
- removeAllKeyframes,
197
177
  setArcPath,
198
178
  updateArcSegment,
179
+ ...gsapScriptEdits
199
180
  } = useGsapScriptCommits({
200
181
  projectIdRef,
201
182
  activeCompPath,
@@ -211,8 +192,6 @@ export function useDomEditSession({
211
192
  forceReloadSdkSession,
212
193
  });
213
194
 
214
- // ── DOM commit handlers ──
215
-
216
195
  const {
217
196
  resolveImportedFontAsset,
218
197
  handleDomStyleCommit,
@@ -222,6 +201,7 @@ export function useDomEditSession({
222
201
  handleDomAttributeQuietCommit,
223
202
  handleDomHtmlAttributeCommit,
224
203
  handleDomAttributesCommit,
204
+ handleDomAttributeBatchCommit,
225
205
  handleDomTextCommit,
226
206
  handleDomTextCommitForSelection,
227
207
  handleDomRichTextCommit,
@@ -308,7 +288,6 @@ export function useDomEditSession({
308
288
  });
309
289
 
310
290
  // ── Element groups (wrap selected elements in a data-hf-group div) ──
311
-
312
291
  const { groupSelection, ungroupSelection } = useGroupCommits({
313
292
  activeCompPath,
314
293
  showToast,
@@ -383,7 +362,6 @@ export function useDomEditSession({
383
362
  }, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
384
363
 
385
364
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
386
-
387
365
  const {
388
366
  onClickToSource,
389
367
  selectedGsapAnimations,
@@ -430,23 +408,7 @@ export function useDomEditSession({
430
408
  applyDomSelection,
431
409
  buildDomSelectionFromTarget,
432
410
  openSourceForSelection,
433
- updateGsapProperty,
434
- updateGsapMeta,
435
- deleteGsapAnimation,
436
- deleteAllForSelector,
437
- addGsapAnimation,
438
- addGsapProperty,
439
- removeGsapProperty,
440
- updateGsapFromProperty,
441
- addGsapFromProperty,
442
- removeGsapFromProperty,
443
- addKeyframe,
444
- addKeyframeBatch,
445
- removeKeyframe,
446
- moveKeyframe,
447
- resizeKeyframedTween,
448
- convertToKeyframes,
449
- removeAllKeyframes,
411
+ ...gsapScriptEdits,
450
412
  handleDomManualEditsReset,
451
413
  });
452
414
  const {
@@ -492,8 +454,8 @@ export function useDomEditSession({
492
454
  handleDomBoxSizeCommit,
493
455
  handleDomRotationCommit: handleDomCssRotationCommit,
494
456
  commitPositionPatchToHtml,
495
- addGsapAnimation,
496
- convertToKeyframes,
457
+ addGsapAnimation: gsapScriptEdits.addGsapAnimation,
458
+ convertToKeyframes: gsapScriptEdits.convertToKeyframes,
497
459
  setArcPath,
498
460
  updateArcSegment,
499
461
  });
@@ -532,6 +494,7 @@ export function useDomEditSession({
532
494
  handleDomAttributeQuietCommit,
533
495
  handleDomHtmlAttributeCommit,
534
496
  handleDomAttributesCommit,
497
+ handleDomAttributeBatchCommit,
535
498
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
536
499
  handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
537
500
  handleDomZIndexReorderCommit,
@@ -1,10 +1,6 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
3
- import {
4
- injectPreviewGoogleFont,
5
- injectPreviewImportedFont,
6
- ensureImportedFontFace,
7
- } from "../utils/studioFontHelpers";
3
+ import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
8
4
  import {
9
5
  buildDomEditRichTextPatchOperation,
10
6
  findElementForSelection,
@@ -99,6 +95,7 @@ export function useDomEditTextCommits({
99
95
  handleDomAttributeQuietCommit,
100
96
  handleDomHtmlAttributeCommit,
101
97
  handleDomAttributesCommit,
98
+ handleDomAttributeBatchCommit,
102
99
  } = useDomEditAttributeCommits({
103
100
  activeCompPath,
104
101
  previewIframeRef,
@@ -354,9 +351,7 @@ export function useDomEditTextCommits({
354
351
  await persistDomEditOperations(selection, textCommit.operations, {
355
352
  label: "Edit text",
356
353
  skipRefresh: true,
357
- prepareContent: importedFont
358
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
359
- : undefined,
354
+ importedFont: importedFont ?? undefined,
360
355
  });
361
356
  },
362
357
  shouldRevert: () => isLatestTextCommit(),
@@ -485,6 +480,7 @@ export function useDomEditTextCommits({
485
480
  handleDomAttributeQuietCommit,
486
481
  handleDomHtmlAttributeCommit,
487
482
  handleDomAttributesCommit,
483
+ handleDomAttributeBatchCommit,
488
484
  handleDomTextCommit,
489
485
  handleDomTextCommitForSelection,
490
486
  handleDomRichTextCommit,
@@ -32,7 +32,13 @@ export interface UseDomGeometryCommitsParams {
32
32
  commitPositionPatchToHtml: (
33
33
  selection: DomEditSelection,
34
34
  patches: PatchOperation[],
35
- options: { label: string; coalesceKey: string; coalesceMs?: number; skipRefresh?: boolean },
35
+ options: {
36
+ label: string;
37
+ coalesceKey: string;
38
+ coalesceMs?: number;
39
+ skipRefresh?: boolean;
40
+ deferRender?: boolean;
41
+ },
36
42
  ) => Promise<void>;
37
43
  readOnlyPreview: boolean;
38
44
  }
@@ -65,6 +71,7 @@ export function useDomGeometryCommits({
65
71
  next: { width: number; height: number },
66
72
  offset?: { x: number; y: number },
67
73
  restore?: () => void,
74
+ undoKey?: string,
68
75
  ) => {
69
76
  if (readOnlyPreview) return Promise.resolve();
70
77
  const element = selection.element;
@@ -80,7 +87,9 @@ export function useDomGeometryCommits({
80
87
  if (offset) patches.push(...writePlainMove(element, offset));
81
88
  return commitPositionPatchToHtml(selection, patches, {
82
89
  label: "Resize layer box",
83
- coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
90
+ ...(undoKey
91
+ ? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
92
+ : { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }),
84
93
  }).catch((error) => {
85
94
  restoreStudioBoxSize(element, beforeSize);
86
95
  if (offset) restoreStudioPathOffset(element, beforeOffset);
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
- import { act, createElement } from "react";
2
+ import { act, createElement, type ReactElement } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
@@ -10,6 +10,11 @@ import {
10
10
  openProjectHistory,
11
11
  type StudioApiAdapter,
12
12
  } from "@hyperframes/studio-server";
13
+ import { useDomEditNudge } from "../components/editor/useDomEditNudge";
14
+ import { DomEditProvider, useDomEditActionsContext } from "../contexts/DomEditContext";
15
+ import { __resetForTests as resetNudgeKeys } from "../utils/canvasNudgeGate";
16
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
17
+ import { makeSelection } from "./domSelectionTestHarness";
13
18
  import { useEditHistoryActions } from "./useEditHistoryActions";
14
19
  import { usePersistentEditHistory } from "./usePersistentEditHistory";
15
20
  import { usePreviewPersistence } from "./usePreviewPersistence";
@@ -19,6 +24,7 @@ const page = (left: string, top = "0px") =>
19
24
  const BEFORE = page("10px");
20
25
  const AFTER = page("50px");
21
26
  const OUTSIDE = page("50px", "99px");
27
+ const NUDGED = page("51px");
22
28
 
23
29
  const cleanup: Array<() => unknown> = [];
24
30
  let scratch = "";
@@ -78,14 +84,15 @@ async function studio() {
78
84
  showToast: () => {},
79
85
  syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
80
86
  showHistoryRestoreNow: persistence.showHistoryRestoreNow,
81
- waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
87
+ waitForPendingDomEditSaves: persistence.settlePendingEdits,
82
88
  });
83
89
  return null;
84
90
  }
85
91
  const root = createRoot(document.createElement("div"));
86
92
  await act(async () => root.render(createElement(Harness)));
87
93
  cleanup.push(() => act(() => root.unmount()));
88
- const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
94
+ const element = (id: string) => iframe.contentDocument!.getElementById(id)!;
95
+ const box = () => element("box").style.left;
89
96
  const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
90
97
  const show = (html: string) => {
91
98
  iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
@@ -105,8 +112,16 @@ async function studio() {
105
112
  );
106
113
  await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
107
114
  };
115
+ /** Mounts `ui` beside Studio, as the canvas is, for the rest of the test. */
116
+ const mount = (ui: ReactElement) => {
117
+ const canvas = createRoot(document.createElement("div"));
118
+ act(() => canvas.render(ui));
119
+ cleanup.push(() => act(() => canvas.unmount()));
120
+ };
108
121
  return {
109
122
  history: () => history,
123
+ element,
124
+ mount,
110
125
  persistence: () => persistence,
111
126
  actions: () => actions,
112
127
  box,
@@ -164,3 +179,107 @@ it("an undo after an outside write ends with the preview showing the file the se
164
179
  expect(s.other()).toBe("0px");
165
180
  expect(s.box()).toBe("50px");
166
181
  });
182
+
183
+ /** The canvas's arrow-key nudge on the box; `save` stands in for the burst's save once the keys stop. */
184
+ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void> }) {
185
+ const selection = makeSelection("Box", target);
186
+ const ref = <T,>(current: T) => ({ current });
187
+ useDomEditNudge({
188
+ selection,
189
+ groupSelections: [],
190
+ allowCanvasMovement: true,
191
+ selectionRef: ref(selection),
192
+ overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }),
193
+ groupOverlayItemsRef: ref([]),
194
+ gestureRef: ref(null),
195
+ groupGestureRef: ref(null),
196
+ blockedMoveRef: ref(null),
197
+ onManualDragStartRef: ref(() => {}),
198
+ onBlockedMoveRef: ref(() => {}),
199
+ onPathOffsetCommitRef: ref(save),
200
+ onGroupPathOffsetCommitRef: ref(async () => {}),
201
+ });
202
+ return null;
203
+ }
204
+
205
+ it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
206
+ const s = await studio();
207
+ await s.edit();
208
+ resetNudgeKeys();
209
+ const save = vi.fn(async () => {
210
+ writeFileSync(s.path, NUDGED);
211
+ await s.history().recordEdit({
212
+ label: "Move layer",
213
+ files: { "index.html": { before: AFTER, after: NUDGED } },
214
+ });
215
+ });
216
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
217
+ act(() => {
218
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
219
+ });
220
+
221
+ const undone = s.actions().undo();
222
+ expect(s.box()).toBe("50px");
223
+ await act(() => undone);
224
+ expect(save).toHaveBeenCalledTimes(1);
225
+ expect(s.file()).toBe(AFTER);
226
+ expect(s.box()).toBe("50px");
227
+ });
228
+
229
+ it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
230
+ const s = await studio();
231
+ await s.edit();
232
+ const box = s.element("box");
233
+ let fail!: () => void;
234
+ const handleDomStyleCommit = vi.fn(async () => {
235
+ box.style.left = "70px";
236
+ try {
237
+ await s.persistence().queueDomEditSave(async () => {
238
+ await new Promise<void>((resolve) => (fail = resolve));
239
+ throw new Error("The save failed.");
240
+ });
241
+ } catch (error) {
242
+ box.style.left = "50px";
243
+ throw error;
244
+ }
245
+ });
246
+ let actions!: ReturnType<typeof useDomEditActionsContext>;
247
+ function Canvas() {
248
+ actions = useDomEditActionsContext();
249
+ return null;
250
+ }
251
+ const value = { handleDomStyleCommit } as unknown as Parameters<
252
+ typeof DomEditProvider
253
+ >[0]["value"];
254
+ s.mount(
255
+ <DomEditProvider value={value}>
256
+ <Canvas />
257
+ </DomEditProvider>,
258
+ );
259
+ const failed = actions.handleDomStyleCommit("left", "70px");
260
+
261
+ const undone = s.actions().undo();
262
+ await vi.waitFor(() => expect(fail).toBeTypeOf("function"));
263
+ fail();
264
+ await expect(failed).rejects.toThrow("The save failed.");
265
+ await act(() => undone);
266
+ expect(s.file()).toBe(BEFORE);
267
+ expect(s.box()).toBe("10px");
268
+ });
269
+
270
+ it("an undo pressed while a tracked timeline edit fails still undoes the edit before it", async () => {
271
+ const s = await studio();
272
+ await s.edit();
273
+ let fail!: () => void;
274
+ const timelineEdit = trackedStudioEdit(
275
+ () => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
276
+ );
277
+ const failed = timelineEdit();
278
+
279
+ const undone = s.actions().undo();
280
+ fail();
281
+ await expect(failed).rejects.toThrow("The save failed.");
282
+ await act(() => undone);
283
+ expect(s.file()).toBe(BEFORE);
284
+ expect(s.box()).toBe("10px");
285
+ });
@@ -93,6 +93,7 @@ export interface UseGsapAwareEditingParams {
93
93
  next: { width: number; height: number },
94
94
  offset?: { x: number; y: number },
95
95
  restore?: () => void,
96
+ undoKey?: string,
96
97
  ) => Promise<void>;
97
98
  commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
98
99
  // GSAP script commit ops (from useGsapScriptCommits)
@@ -394,12 +395,11 @@ export function useGsapAwareEditing({
394
395
  makeFetchFallback(selection),
395
396
  );
396
397
  assertGsapEditPersisted(outcome);
397
- cropUndoKey = coalesceKey;
398
- // What the resize actually did, not what its animations suggest
399
- // it would do. An element whose scale is an instant hold has a
400
- // scale-group tween and still commits width/height, so guessing
401
- // from the tweens withheld an offset nobody had written and the
402
- // element snapped back to its authored position on every drag.
398
+ // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
399
+ if (outcome.status === "element-size")
400
+ await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
401
+ else cropUndoKey = coalesceKey;
402
+ // What the resize did, not what its tweens suggest: a scale hold still commits a size.
403
403
  const ownsDragOffset =
404
404
  outcome.status === "persisted" && outcome.ownsDragOffset === true;
405
405
  logResize("intercept-handled", {
@@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
30
30
  import type { MutationResult } from "./gsapScriptCommitTypes";
31
31
  import { persistSdkSerialize } from "../utils/sdkCutover";
32
32
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
33
+ import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
33
34
 
34
35
  // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
35
36
 
@@ -426,6 +427,24 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
426
427
  );
427
428
  }
428
429
 
430
+ describe("a GSAP script commit", () => {
431
+ it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => {
432
+ mockFetchResult();
433
+ const deps = renderCommitHook();
434
+ let committed!: Promise<unknown>;
435
+ act(() => {
436
+ committed = deps.api.commitMutation(
437
+ selection,
438
+ { type: "remove-all-keyframes", animationId: "a" },
439
+ { label: "Remove all keyframes" },
440
+ );
441
+ });
442
+ expect(hasStudioPendingEdits()).toBe(true);
443
+ await act(async () => void (await committed));
444
+ expect(hasStudioPendingEdits()).toBe(false);
445
+ });
446
+ });
447
+
429
448
  describe("runCommit — instantPatch wiring", () => {
430
449
  it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
431
450
  mockFetchResult({ changed: false });
@@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"
7
7
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
9
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
10
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
10
11
  import {
11
12
  getStudioSaveErrorMessage,
12
13
  isStudioSaveErrorAlreadyToasted,
@@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
416
417
  }
417
418
  return run();
418
419
  };
419
- const commit: CommitMutation = (selection, mutation, options) => {
420
+ const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
420
421
  if (!activeProjectId) return Promise.resolve();
421
422
  const file = selection.sourceFile || activeCompPath || "index.html";
422
423
  return serializeCommit(file, options.serializeKey, () =>
423
424
  runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
424
425
  );
425
- };
426
- commit.batch = (calls, options) => {
426
+ }) as CommitMutation;
427
+ commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
427
428
  if (!activeProjectId) return Promise.resolve();
428
429
  const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
429
430
  return serializeCommit(file, options.serializeKey, () =>
430
431
  runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
431
432
  );
432
- };
433
+ });
433
434
  return commit;
434
435
  }, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
435
436
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
@@ -336,8 +336,7 @@ export function usePopulateKeyframeCacheForFile(
336
336
  // fallow-ignore-next-line complexity
337
337
  const tryRuntimeScan = () => {
338
338
  if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
339
- const iframe =
340
- iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
339
+ const iframe = iframeRef?.current;
341
340
  if (!iframe) return false;
342
341
  // Clip dims per element so the scan converts tween-relative keyframes to
343
342
  // clip-relative (matching the static path) instead of timeline-relative.
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
7
7
  import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
8
+ import { applyUndoRestoreToPreview } from "../utils/gsapUndoRestore";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -643,3 +644,29 @@ describe("useInlineTextEdit with styled runs", () => {
643
644
  act(() => root.unmount());
644
645
  });
645
646
  });
647
+
648
+ describe("an undo that lands while a text edit is open", () => {
649
+ it("marks the edit as drawing nothing, so the undo reverts the move and keeps the edit", () => {
650
+ const wrap = (body: string) => `<html><body>${body}</body></html>`;
651
+ const moved = `<h1 id="t" style="translate: 90px 60px">Title</h1>`;
652
+ document.body.innerHTML = moved;
653
+ const element = document.getElementById("t")!;
654
+ const { controls, root } = mount();
655
+ act(() => {
656
+ controls().start(element);
657
+ });
658
+ const contentWindow = { __player: { seek: vi.fn() }, __hfStudioManualEditsApply: vi.fn() };
659
+ const iframe = { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement;
660
+ const files = {
661
+ "index.html": { previous: wrap(moved), restored: wrap(`<h1 id="t">Title</h1>`) },
662
+ };
663
+
664
+ expect(element.getAttribute("data-hf-studio-manual-edit-gesture")).toMatch(/:edit$/);
665
+ expect(applyUndoRestoreToPreview(iframe, "index.html", files, 0, vi.fn())).toBe("soft");
666
+
667
+ expect(element.style.getPropertyValue("translate")).toBe("");
668
+ expect(element.getAttribute("contenteditable")).toBe("true");
669
+ expect(element.style.getPropertyValue("outline")).not.toBe("");
670
+ act(() => root.unmount());
671
+ });
672
+ });
@@ -1,6 +1,12 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
3
  import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
4
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
5
+ import {
6
+ beginStudioManualEditGesture,
7
+ endStudioManualEditGesture,
8
+ countStudioManualEditSave,
9
+ } from "../components/editor/manualEditsDom";
4
10
 
5
11
  /**
6
12
  * Editing an element's text where it sits, in the composition itself.
@@ -43,6 +49,8 @@ export interface InlineTextEditSession {
43
49
  outline: string;
44
50
  /** The element's own outline offset, restored with the outline. */
45
51
  outlineOffset: string;
52
+ /** Holds the preview on screen while the edit is open. */
53
+ gesture?: string;
46
54
  }
47
55
 
48
56
  /** The live markup to persist and the session snapshot to restore on failure. */
@@ -86,7 +94,7 @@ export function useInlineTextEdit({
86
94
  onPause,
87
95
  }: {
88
96
  /** Where the edited text goes. The caller owns persistence. */
89
- onCommit: (commit: InlineTextEditCommit) => void;
97
+ onCommit: (commit: InlineTextEditCommit) => unknown;
90
98
  /** Stop playback, so the element is not animating under the caret. */
91
99
  onPause?: () => void;
92
100
  }): InlineTextEditControls {
@@ -106,6 +114,7 @@ export function useInlineTextEdit({
106
114
  framesRef.current = null;
107
115
  }
108
116
  openRef.current = null;
117
+ endStudioManualEditGesture(open.element, open.gesture);
109
118
  setSession(null);
110
119
  // An element removed from the document mid-session is not an error, it is
111
120
  // just nothing left to clean up.
@@ -132,6 +141,7 @@ export function useInlineTextEdit({
132
141
  original: element.innerHTML,
133
142
  outline: element.style.outline,
134
143
  outlineOffset: element.style.outlineOffset,
144
+ gesture: beginStudioManualEditGesture(element, "edit"),
135
145
  };
136
146
  // Drawn on the element itself, not in Studio's overlay above it. This is
137
147
  // the only mark that says the caret is in the TEXT rather than the
@@ -168,10 +178,16 @@ export function useInlineTextEdit({
168
178
  // saved rather than something the server will quietly cut down.
169
179
  sanitizeRichTextChildren(open.element);
170
180
  const html = open.element.innerHTML;
171
- teardown();
172
- // After teardown, so the commit path's own resync does not fight an
173
- // element that is still editable.
174
- onCommit({ element: open.element, html, previousHtml: open.original });
181
+ // Counted and pending before the edit closes, so a reload waits for the save, not the mark.
182
+ let save: (saving: unknown) => void = () => {};
183
+ trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
184
+ const commitText = () => {
185
+ teardown();
186
+ // After teardown, so the commit path's own resync does not fight an
187
+ // element that is still editable.
188
+ return onCommit({ element: open.element, html, previousHtml: open.original });
189
+ };
190
+ save(countStudioManualEditSave(open.element, commitText));
175
191
  }, [onCommit, teardown]);
176
192
 
177
193
  const cancel = useCallback(() => {
@@ -86,6 +86,21 @@ describe("keyframe shortcuts", () => {
86
86
  window.removeEventListener("keydown", playback);
87
87
  });
88
88
 
89
+ describe("with a focused picker", () => {
90
+ const picker = document.createElement("button");
91
+ picker.setAttribute("role", "combobox");
92
+ afterEach(() => picker.remove());
93
+
94
+ it("leaves K to a focused picker that owns typing, like a font combobox", () => {
95
+ const onAddKeyframe = mountKeyframeKeyboard(true);
96
+ document.body.append(picker);
97
+ picker.focus();
98
+
99
+ expect(press("k").defaultPrevented).toBe(false);
100
+ expect(onAddKeyframe).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+
89
104
  it("K stays playback's stop key when no keyframeable element is selected", () => {
90
105
  const playback = vi.fn();
91
106
  window.addEventListener("keydown", playback);
@@ -1,23 +1,17 @@
1
1
  import { useEffect, useCallback } from "react";
2
2
  import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
3
+ import { isTypingTarget } from "../utils/typingTarget";
3
4
 
4
5
  interface KeyframeKeyboardOptions {
5
6
  enabled: boolean;
6
7
  onAddKeyframe?: () => void;
7
8
  }
8
9
 
9
- function isTextInput(el: Element | null): boolean {
10
- if (!el) return false;
11
- const tag = el.tagName;
12
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
13
- return (el as HTMLElement).isContentEditable === true;
14
- }
15
-
16
10
  export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
17
11
  const handler = useCallback(
18
12
  (e: KeyboardEvent) => {
19
13
  if (!enabled || !onAddKeyframe) return;
20
- if (isTextInput(document.activeElement)) return;
14
+ if (isTypingTarget(document.activeElement)) return;
21
15
  if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
22
16
  if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
23
17
  // K is also playback's stop key: claiming it here keeps playback from also pausing.
@@ -0,0 +1,18 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { onPreviewPromoted } from "../player/sceneSwap";
3
+
4
+ /** The one source for which preview is on screen: the host's iframe, then each reload promoted in its place. */
5
+ export function useLivePreviewIframe(host: HTMLIFrameElement | null): HTMLIFrameElement | null {
6
+ const [promoted, setPromoted] = useState<{ host: HTMLIFrameElement; live: HTMLIFrameElement }>();
7
+ const live = host && promoted?.host === host ? promoted.live : host;
8
+ const liveRef = useRef(live);
9
+ liveRef.current = live;
10
+ // eslint-disable-next-line no-restricted-syntax
11
+ useEffect(() => {
12
+ if (!host) return;
13
+ return onPreviewPromoted(host.ownerDocument, ({ retired, live: next }) => {
14
+ if (retired === liveRef.current) setPromoted({ host, live: next });
15
+ });
16
+ }, [host]);
17
+ return live;
18
+ }