@hyperframes/studio 0.8.115 → 0.8.116

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 (149) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
  4. package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +104 -22
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1131 -1044
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  16. package/src/components/editor/domGroupMoveBy.ts +95 -0
  17. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  18. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  19. package/src/components/editor/manualEditingAvailability.ts +8 -0
  20. package/src/components/editor/manualOffsetDrag.ts +5 -6
  21. package/src/components/editor/motionPathSelection.test.ts +6 -31
  22. package/src/components/editor/probeSourceElement.ts +2 -1
  23. package/src/components/editor/propertyPanelFont.tsx +3 -2
  24. package/src/components/editor/useColorGradingController.ts +2 -1
  25. package/src/components/editor/useFxCarveNodes.ts +2 -1
  26. package/src/components/editor/useFxLevelling.ts +2 -1
  27. package/src/components/feedback/projectProvenance.ts +2 -1
  28. package/src/components/nle/NLEContext.test.ts +1 -0
  29. package/src/components/nle/NLEContext.tsx +2 -1
  30. package/src/components/nle/NLEPreview.tsx +12 -10
  31. package/src/components/nle/PreviewPoster.tsx +2 -1
  32. package/src/components/renders/useFfmpegStatus.ts +2 -1
  33. package/src/components/renders/useRenderQueue.ts +5 -4
  34. package/src/components/sidebar/AssetsTab.tsx +2 -1
  35. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  36. package/src/contexts/DomEditContext.tsx +4 -0
  37. package/src/hooks/domSelectionTestHarness.ts +23 -0
  38. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  39. package/src/hooks/domStyleCommit.ts +8 -1
  40. package/src/hooks/draggedGsapPosition.ts +10 -0
  41. package/src/hooks/gsapDragCommit.test.ts +75 -164
  42. package/src/hooks/gsapDragCommit.ts +2 -105
  43. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  44. package/src/hooks/gsapEditOutcome.ts +22 -2
  45. package/src/hooks/gsapGroupPreflight.ts +34 -0
  46. package/src/hooks/gsapMutationClient.ts +4 -3
  47. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  48. package/src/hooks/gsapParsedTween.test.ts +62 -0
  49. package/src/hooks/gsapParsedTween.ts +243 -0
  50. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  51. package/src/hooks/gsapPositionDetection.ts +52 -33
  52. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  53. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  55. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  56. package/src/hooks/gsapResizeIntercept.ts +79 -169
  57. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  58. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  59. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  60. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  61. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  62. package/src/hooks/gsapScriptCommitTypes.ts +8 -0
  63. package/src/hooks/gsapShared.ts +7 -0
  64. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  65. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  66. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  67. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  68. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  69. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  70. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  71. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  72. package/src/hooks/newTweenTarget.test.ts +41 -76
  73. package/src/hooks/timelineTimingSync.test.ts +1 -0
  74. package/src/hooks/timelineTimingSync.ts +2 -1
  75. package/src/hooks/useAskAgentModal.ts +2 -1
  76. package/src/hooks/useBlockCatalog.ts +2 -1
  77. package/src/hooks/useCaptionDetection.ts +2 -1
  78. package/src/hooks/useCompositionContentLoader.ts +2 -1
  79. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  80. package/src/hooks/useDomEditSession.test.tsx +30 -1
  81. package/src/hooks/useDomEditSession.ts +10 -0
  82. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  83. package/src/hooks/useDomEditTextCommits.ts +8 -2
  84. package/src/hooks/useDomGeometryCommit.ts +1 -0
  85. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  86. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  87. package/src/hooks/useEditHistoryActions.ts +16 -5
  88. package/src/hooks/useElementLifecycleOps.ts +2 -1
  89. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  90. package/src/hooks/useEnableKeyframes.ts +2 -1
  91. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  92. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  94. package/src/hooks/useFileManager.ts +14 -10
  95. package/src/hooks/useFileTree.ts +3 -2
  96. package/src/hooks/useFrameCapture.ts +5 -1
  97. package/src/hooks/useFreezeFrame.ts +3 -2
  98. package/src/hooks/useGroupCommits.ts +2 -1
  99. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  100. package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
  101. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  102. package/src/hooks/useGsapAwareEditing.ts +5 -0
  103. package/src/hooks/useGsapAwareGroupMove.ts +16 -16
  104. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  105. package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
  106. package/src/hooks/useGsapScriptCommits.ts +12 -10
  107. package/src/hooks/useLintModal.ts +2 -1
  108. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  109. package/src/hooks/usePersistentEditHistory.ts +47 -8
  110. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  111. package/src/hooks/usePreviewPersistence.ts +13 -11
  112. package/src/hooks/useProjectFileWriter.ts +20 -19
  113. package/src/hooks/useRemoveBackground.ts +2 -1
  114. package/src/hooks/useSdkSession.ts +2 -1
  115. package/src/hooks/useServerConnection.ts +4 -3
  116. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  117. package/src/hooks/useThumbnailStill.test.ts +100 -0
  118. package/src/hooks/useThumbnailStill.ts +37 -0
  119. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  120. package/src/index.ts +4 -0
  121. package/src/player/components/AudioWaveform.tsx +3 -2
  122. package/src/player/components/CompositionThumbnail.tsx +2 -1
  123. package/src/player/components/clipMenuNormalize.ts +2 -1
  124. package/src/player/components/clipPeakMap.ts +2 -1
  125. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  126. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  127. package/src/player/lib/thumbnailScheduler.ts +10 -2
  128. package/src/player/sceneSwap.ts +2 -1
  129. package/src/telemetry/client.ts +2 -1
  130. package/src/utils/blockInstaller.ts +2 -1
  131. package/src/utils/elementGsap.test.ts +9 -0
  132. package/src/utils/elementGsap.ts +7 -0
  133. package/src/utils/gsapSoftReload.ts +2 -3
  134. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  135. package/src/utils/gsapUndoRestore.ts +3 -6
  136. package/src/utils/previewPrefetch.ts +2 -1
  137. package/src/utils/razorSplitTransaction.ts +12 -8
  138. package/src/utils/softReloadTargets.ts +1 -3
  139. package/src/utils/studioApiFetch.test.ts +75 -0
  140. package/src/utils/studioApiFetch.ts +13 -0
  141. package/src/utils/studioFileHistory.ts +4 -1
  142. package/src/utils/studioHelpers.ts +7 -1
  143. package/src/utils/studioPendingEdits.test.ts +23 -6
  144. package/src/utils/studioPendingEdits.ts +32 -8
  145. package/src/utils/studioTelemetry.ts +2 -1
  146. package/src/utils/timelineCompositionInsert.ts +3 -2
  147. package/src/webmcp/StudioAgentTools.tsx +2 -1
  148. package/dist/chunk-TXZMMOMA.js.map +0 -1
  149. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -5,6 +5,7 @@ import {
5
5
  type BlockCategory,
6
6
  resolveBlockCategory,
7
7
  } from "../utils/blockCategories";
8
+ import { studioApiFetch } from "../utils/studioApiFetch";
8
9
 
9
10
  type CatalogItem = RegistryItem & {
10
11
  category: BlockCategory;
@@ -14,7 +15,7 @@ let catalogCache: CatalogItem[] | null = null;
14
15
  let catalogRequest: Promise<CatalogItem[]> | null = null;
15
16
 
16
17
  function loadCatalog(): Promise<CatalogItem[]> {
17
- catalogRequest ??= fetch("/api/registry/blocks")
18
+ catalogRequest ??= studioApiFetch("/api/registry/blocks")
18
19
  .then((response) => {
19
20
  if (!response.ok) throw new Error("Failed to load catalog");
20
21
  return response.json() as Promise<RegistryItem[]>;
@@ -5,6 +5,7 @@ import { useCaptionStore } from "../captions/store";
5
5
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
6
6
  import { useCaptionSync } from "../captions/hooks/useCaptionSync";
7
7
  import { parseCaptionComposition } from "../captions/parser";
8
+ import { studioApiFetch } from "../utils/studioApiFetch";
8
9
 
9
10
  interface UseCaptionDetectionParams {
10
11
  projectId: string | null;
@@ -110,7 +111,7 @@ export function useCaptionDetection({
110
111
 
111
112
  activating = true;
112
113
  const srcPath = captionSrcPath;
113
- fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`))
114
+ studioApiFetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`))
114
115
  .then((r) => r.json())
115
116
  .then((data: { content?: string }) => {
116
117
  if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
@@ -1,5 +1,6 @@
1
1
  import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  import { useCallback } from "react";
3
+ import { studioApiFetch } from "../utils/studioApiFetch";
3
4
 
4
5
  /**
5
6
  * Loads a composition file's content for the source editor when a composition
@@ -24,7 +25,7 @@ export function useCompositionContentLoader({
24
25
  if (!projectId) return;
25
26
  setActiveCompPath(comp.endsWith(".html") ? comp : null);
26
27
  setEditingFile({ path: comp, content: null });
27
- fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`))
28
+ studioApiFetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`))
28
29
  .then(async (r) => {
29
30
  if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
30
31
  return r.json();
@@ -7,6 +7,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
7
7
  import type { DomEditPatchBatch } from "./domEditCommitTypes";
8
8
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
9
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
10
+ import { studioApiFetch } from "../utils/studioApiFetch";
10
11
 
11
12
  export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
12
13
  return fields.map((field) => field.path).join(", ");
@@ -99,7 +100,7 @@ function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFi
99
100
  export async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
100
101
  const body = JSON.stringify({ batches });
101
102
  try {
102
- const response = await fetch(
103
+ const response = await studioApiFetch(
103
104
  `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
104
105
  {
105
106
  method: "POST",
@@ -178,7 +179,7 @@ export async function postPatchElement(
178
179
  body: unknown,
179
180
  showToast: ShowToast,
180
181
  ): Promise<PatchElementResponse> {
181
- const response = await fetch(
182
+ const response = await studioApiFetch(
182
183
  buildProjectApiPath(
183
184
  projectId,
184
185
  `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
@@ -10,6 +10,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import type { TimelineElement } from "../player";
11
11
  import type { UseDomEditSessionParams } from "./useDomEditSession";
12
12
  import { makeSelection } from "./domSelectionTestHarness";
13
+ import { readTranslatePx } from "../components/editor/plainTranslate";
13
14
 
14
15
  const styleOp = (property: string, value: string): PatchOperation => ({
15
16
  type: "inline-style",
@@ -66,6 +67,7 @@ const groupSelectionSpy = vi.fn();
66
67
  const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
67
68
  const neverParsed = new Promise<{ animations: GsapAnimation[] }>(() => undefined);
68
69
  const parsedFile = { current: neverParsed };
70
+ const groupCommitSpy = vi.fn(async () => {});
69
71
 
70
72
  function createSessionParams(
71
73
  overrides: Partial<UseDomEditSessionParams> = {},
@@ -256,7 +258,7 @@ vi.mock("./usePreviewInteraction", () => ({
256
258
  vi.mock("./useGsapAwareEditing", () => ({
257
259
  useGsapAwareEditing: () => ({
258
260
  handleGsapAwarePathOffsetCommit: vi.fn(),
259
- handleGsapAwareGroupPathOffsetCommit: vi.fn(),
261
+ handleGsapAwareGroupPathOffsetCommit: groupCommitSpy,
260
262
  handleGsapAwareBoxSizeCommit: vi.fn(),
261
263
  handleGsapAwareRotationCommit: vi.fn(),
262
264
  commitAnimatedProperty: vi.fn(),
@@ -605,3 +607,30 @@ describe("a shadow reload promoted without a host re-render", () => {
605
607
  act(() => root.unmount());
606
608
  });
607
609
  });
610
+
611
+ describe("handleDomGroupMoveBy", () => {
612
+ it("saves through the group drag's commit, leaving a refusal for the host to explain", async () => {
613
+ const { useDomEditSession } = await import("./useDomEditSession");
614
+ const element = document.createElement("div");
615
+ document.body.append(element);
616
+ element.getBoundingClientRect = () => {
617
+ const { x, y } = readTranslatePx(element);
618
+ return new DOMRect(x, y, 40, 20);
619
+ };
620
+ const selection = makeSelection("card", element);
621
+ let moveBy!: ReturnType<typeof useDomEditSession>["handleDomGroupMoveBy"];
622
+ function Probe() {
623
+ moveBy = useDomEditSession(createSessionParams()).handleDomGroupMoveBy;
624
+ return null;
625
+ }
626
+ const root = createRoot(document.createElement("div"));
627
+ act(() => root.render(<Probe />));
628
+ await act(() => moveBy([{ selection, delta: { x: 10, y: 0 } }]));
629
+ expect(groupCommitSpy).toHaveBeenCalledWith(
630
+ [{ selection, next: { x: 10, y: 0 }, plainTranslate: true }],
631
+ { refusalToast: false },
632
+ );
633
+ act(() => root.unmount());
634
+ element.remove();
635
+ });
636
+ });
@@ -25,6 +25,7 @@ import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMemb
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
26
  import type { DomEditTimelineParams } from "./useDomSelectionTypes";
27
27
  import { useLivePreviewIframe } from "./useLivePreviewIframe";
28
+ import { moveDomGroupBy, type DomGroupMove } from "../components/editor/domGroupMoveBy";
28
29
  // Re-exported: the delete rule lives in its own module; callers and its test import it from here.
29
30
  export { membersForDelete };
30
31
 
@@ -445,6 +446,7 @@ export function useDomEditSession({
445
446
  domEditSelection,
446
447
  selectedGsapAnimations,
447
448
  gsapCommitMutation,
449
+ activeCompPath,
448
450
  previewIframeRef,
449
451
  showToast,
450
452
  bumpGsapCache,
@@ -459,6 +461,13 @@ export function useDomEditSession({
459
461
  setArcPath,
460
462
  updateArcSegment,
461
463
  });
464
+ const handleDomGroupMoveBy = useCallback(
465
+ (moves: DomGroupMove[]) =>
466
+ moveDomGroupBy(moves, (updates) =>
467
+ handleGsapAwareGroupPathOffsetCommit(updates, { refusalToast: false }),
468
+ ),
469
+ [handleGsapAwareGroupPathOffsetCommit],
470
+ );
462
471
  const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
463
472
  useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
464
473
  const committable = useCommitPreflightCapabilities({
@@ -497,6 +506,7 @@ export function useDomEditSession({
497
506
  handleDomAttributeBatchCommit,
498
507
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
499
508
  handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
509
+ handleDomGroupMoveBy,
500
510
  handleDomZIndexReorderCommit,
501
511
  handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
502
512
  handleDomRotationCommit: handleGsapAwareRotationCommit,
@@ -350,6 +350,29 @@ describe("useDomEditTextCommits", () => {
350
350
  expect(outcome).toEqual({ ok: true, persistence });
351
351
  });
352
352
 
353
+ it("saves a style map on a selection as one patch, so it is one undo step", async () => {
354
+ const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
355
+ const persist = vi.fn().mockResolvedValue({ sourceFile: "index.html", changed: true });
356
+ const hook = renderTextCommitHook(
357
+ commitParams({
358
+ previewIframeRef: { current: iframe },
359
+ domEditSelection: null,
360
+ persistDomEditOperations: persist,
361
+ }),
362
+ );
363
+
364
+ await act(async () => {
365
+ await hook.handleDomStyleCommitForSelection(selectionFor(element), {
366
+ "border-width": "4px",
367
+ "border-style": "solid",
368
+ });
369
+ });
370
+
371
+ expect(persist).toHaveBeenCalledTimes(1);
372
+ expect(element.style.borderWidth).toBe("4px");
373
+ expect(element.style.borderStyle).toBe("solid");
374
+ });
375
+
353
376
  it("declines a style commit with no selection, without reaching the writer", async () => {
354
377
  const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
355
378
  const hook = renderTextCommitHook(
@@ -109,7 +109,11 @@ export function useDomEditTextCommits({
109
109
  });
110
110
 
111
111
  const handleDomStyleCommitForSelection = useCallback(
112
- (selection: DomEditSelection, property: string, value: string): Promise<DomEditCommitOutcome> =>
112
+ (
113
+ selection: DomEditSelection,
114
+ propertyOrStylesAsOnePatch: string | Record<string, string>,
115
+ value = "",
116
+ ): Promise<DomEditCommitOutcome> =>
113
117
  commitDomStyles(
114
118
  {
115
119
  activeCompPath,
@@ -121,7 +125,9 @@ export function useDomEditTextCommits({
121
125
  resync: refreshDomEditSelectionFromPreview,
122
126
  },
123
127
  selection,
124
- { [property]: value },
128
+ typeof propertyOrStylesAsOnePatch === "string"
129
+ ? { [propertyOrStylesAsOnePatch]: value }
130
+ : propertyOrStylesAsOnePatch,
125
131
  ),
126
132
  [
127
133
  activeCompPath,
@@ -128,6 +128,7 @@ export function useDomGeometryCommit({
128
128
  domEditSelection: null,
129
129
  selectedGsapAnimations: NO_SELECTED_ANIMATIONS,
130
130
  gsapCommitMutation: gsap.commitMutation,
131
+ activeCompPath,
131
132
  previewIframeRef: iframeRef,
132
133
  showToast,
133
134
  bumpGsapCache,
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
4
4
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { afterEach, beforeEach, expect, it, vi } from "vitest";
7
+ import { afterEach, beforeEach, expect, it, vi, type Mock } from "vitest";
8
8
  import {
9
9
  createStudioApi,
10
10
  openProjectHistory,
@@ -168,6 +168,20 @@ it("an undo pressed while a save is still running waits for the server instead o
168
168
  expect(s.box()).toBe("10px");
169
169
  });
170
170
 
171
+ it("an undo waits for a save queued before its key, not one queued after", async () => {
172
+ const s = await studio();
173
+ await s.edit();
174
+ let land!: () => void;
175
+ void s.persistence().queueDomEditSave(() => new Promise<void>((resolve) => (land = resolve)));
176
+ await vi.waitFor(() => expect(land).toBeTypeOf("function"));
177
+
178
+ const undone = s.actions().undo();
179
+ void s.persistence().queueDomEditSave(() => new Promise<void>(() => {}));
180
+ land();
181
+ await act(() => undone);
182
+ expect(s.file()).toBe(BEFORE);
183
+ });
184
+
171
185
  it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
172
186
  const s = await studio();
173
187
  await s.edit();
@@ -202,10 +216,8 @@ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void
202
216
  return null;
203
217
  }
204
218
 
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();
219
+ /** A nudge save that waits for `finish()`, then writes and records the nudge as the real save does. */
220
+ function queuedNudgeSave(s: Awaited<ReturnType<typeof studio>>) {
209
221
  let finish!: () => void;
210
222
  const save = vi.fn(async () => {
211
223
  await new Promise<void>((resolve) => (finish = resolve));
@@ -215,6 +227,24 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
215
227
  files: { "index.html": { before: AFTER, after: NUDGED } },
216
228
  });
217
229
  });
230
+ return { save, finish: () => finish() };
231
+ }
232
+
233
+ /** Mounts the nudge, presses ArrowRight once and waits for its save; returns the nudge's translate reader. */
234
+ async function pressNudge(s: Awaited<ReturnType<typeof studio>>, save: Mock<() => Promise<void>>) {
235
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
236
+ act(() => {
237
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
238
+ });
239
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
240
+ return () => s.element("box").style.getPropertyValue("translate");
241
+ }
242
+
243
+ it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
244
+ const s = await studio();
245
+ await s.edit();
246
+ resetNudgeKeys();
247
+ const { save, finish } = queuedNudgeSave(s);
218
248
  s.mount(createElement(Nudge, { target: s.element("box"), save }));
219
249
  act(() => {
220
250
  window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
@@ -240,21 +270,8 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
240
270
  const s = await studio();
241
271
  await s.edit();
242
272
  resetNudgeKeys();
243
- let finish!: () => void;
244
- const save = vi.fn(async () => {
245
- await new Promise<void>((resolve) => (finish = resolve));
246
- writeFileSync(s.path, NUDGED);
247
- await s.history().recordEdit({
248
- label: "Move layer",
249
- files: { "index.html": { before: AFTER, after: NUDGED } },
250
- });
251
- });
252
- s.mount(createElement(Nudge, { target: s.element("box"), save }));
253
- act(() => {
254
- window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
255
- });
256
- await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
257
- const nudge = () => s.element("box").style.getPropertyValue("translate");
273
+ const { save, finish } = queuedNudgeSave(s);
274
+ const nudge = await pressNudge(s, save);
258
275
  expect(nudge()).not.toBe("");
259
276
 
260
277
  const undone = s.actions().undo();
@@ -267,6 +284,51 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
267
284
  expect(s.box()).toBe("50px");
268
285
  });
269
286
 
287
+ /** A nudge whose save is queued and then rejects, and the history requests the undo sends. */
288
+ async function failingNudge(s: Awaited<ReturnType<typeof studio>>) {
289
+ resetNudgeKeys();
290
+ let fail!: () => void;
291
+ const save = vi.fn(
292
+ () => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
293
+ );
294
+ const nudge = await pressNudge(s, save);
295
+ const steps: string[] = [];
296
+ const real = globalThis.fetch;
297
+ vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
298
+ if (/\/history\/(step|undo)$/.test(url)) steps.push(url);
299
+ return real(url, init);
300
+ });
301
+ return { fail: () => fail(), steps, nudge };
302
+ }
303
+
304
+ it("an undo pressed while a nudge's save fails keeps the nudge undone and steps no older edit", async () => {
305
+ const s = await studio();
306
+ await s.edit();
307
+ const n = await failingNudge(s);
308
+ expect(n.nudge()).not.toBe("");
309
+
310
+ const undone = s.actions().undo();
311
+ expect(n.nudge()).toBe("");
312
+ n.fail();
313
+ await act(() => undone);
314
+ expect(n.steps).toEqual([]);
315
+ expect(s.file()).toBe(AFTER);
316
+ expect(s.box()).toBe("50px");
317
+ expect(n.nudge()).toBe("");
318
+ });
319
+
320
+ it("an undo pressed while the only edit's save fails leaves the screen as the file, nothing put back", async () => {
321
+ const s = await studio();
322
+ s.show(BEFORE);
323
+ const n = await failingNudge(s);
324
+
325
+ const undone = s.actions().undo();
326
+ n.fail();
327
+ await act(() => undone);
328
+ expect(s.file()).toBe(BEFORE);
329
+ expect(n.nudge()).toBe("");
330
+ });
331
+
270
332
  it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
271
333
  const s = await studio();
272
334
  await s.edit();
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
7
  import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
8
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -30,6 +31,7 @@ function mount(
30
31
  undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
31
32
  redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
32
33
  predict: () => predicted ?? null,
34
+ claims: () => 7,
33
35
  };
34
36
  const putBack = vi.fn();
35
37
  const deps = {
@@ -60,6 +62,19 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
60
62
  const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
61
63
 
62
64
  describe("useEditHistoryActions", () => {
65
+ it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
66
+ const { deps, actions } = mount({ ok: false, reason: "empty" });
67
+ await act(() => actions.undo());
68
+ expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBeUndefined();
69
+
70
+ let land!: () => void;
71
+ trackStudioPendingEdit(new Promise<void>((resolve) => (land = resolve)));
72
+ const undone = actions.undo();
73
+ land();
74
+ await act(() => undone);
75
+ expect(deps.editHistory.undo.mock.calls[1]![0].claimedAfter).toBe(7);
76
+ });
77
+
63
78
  it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
64
79
  const { deps, actions } = mount(
65
80
  { ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
@@ -3,7 +3,10 @@ import { useCallback, useMemo } from "react";
3
3
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
- import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
6
+ import {
7
+ hasStudioPendingEdits,
8
+ paintBackNewestStudioPendingEdit,
9
+ } from "../utils/studioPendingEdits";
7
10
 
8
11
  interface HistoryResult {
9
12
  ok: boolean;
@@ -18,11 +21,13 @@ interface HistoryResult {
18
21
  interface HistoryFileCallbacks {
19
22
  readFile: (path: string) => Promise<string>;
20
23
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
24
+ claimedAfter?: number;
21
25
  }
22
26
  export interface EditHistoryHandle {
23
27
  undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
24
28
  redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
25
29
  predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
30
+ claims?: () => number;
26
31
  state: {
27
32
  undo: ReadonlyArray<{ createdAt: number }>;
28
33
  redo: ReadonlyArray<{ createdAt: number }>;
@@ -30,7 +35,7 @@ export interface EditHistoryHandle {
30
35
  }
31
36
 
32
37
  export interface UseEditHistoryActionsOptions {
33
- editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
38
+ editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict" | "claims">;
34
39
  readOptionalProjectFile: (path: string) => Promise<string>;
35
40
  readProjectFile: (path: string) => Promise<string>;
36
41
  writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -77,22 +82,28 @@ export function useEditHistoryActions({
77
82
  const predicted = editHistory.predict?.(direction) ?? null;
78
83
  const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
79
84
  const pendingEditShown =
80
- !predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
81
- const putBack = predictedShown ?? pendingEditShown;
85
+ !predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
86
+ const putBack = predictedShown ?? pendingEditShown?.showAgain;
87
+ const claimedAfter =
88
+ direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
82
89
  let result: HistoryResult = { ok: false, reason: "failed" };
83
90
  let serverSteppedShown = false;
91
+ let revertIsTheUndo = false;
84
92
  try {
85
93
  await waitForPendingDomEditSaves();
94
+ revertIsTheUndo = Boolean(pendingEditShown && !(await pendingEditShown.landed()));
95
+ if (revertIsTheUndo) return;
86
96
  result = await editHistory[direction]({
87
97
  readFile: readHistoryFile,
88
98
  serialize: serializeHistoryFiles,
99
+ claimedAfter,
89
100
  });
90
101
  const stepped = Boolean(result.ok && result.label);
91
102
  serverSteppedShown = predictedShown
92
103
  ? stepped && result.undoes === predicted?.id
93
104
  : stepped && Boolean(pendingEditShown);
94
105
  } finally {
95
- if (putBack && !serverSteppedShown) putBack();
106
+ if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
96
107
  }
97
108
  if (!result.ok && result.reason === "content-mismatch") {
98
109
  showToast(
@@ -24,6 +24,7 @@ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditComm
24
24
  import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
25
25
  import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
26
26
  import { studioWriteHeaders } from "../utils/studioFileVersion";
27
+ import { studioApiFetch } from "../utils/studioApiFetch";
27
28
 
28
29
  interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
29
30
  /** Route delete through SDK when session resolves the hf-id. */
@@ -156,7 +157,7 @@ export function useElementLifecycleOps({
156
157
  writeFile: writeProjectFile,
157
158
  recordEdit: editHistory.recordEdit,
158
159
  rewrite: async (originalContent) => {
159
- const removeResponse = await fetch(
160
+ const removeResponse = await studioApiFetch(
160
161
  buildProjectApiPath(
161
162
  pid,
162
163
  `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
@@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection {
378
378
  } as DomEditSelection;
379
379
  }
380
380
 
381
- function stubFlatTweenConversion(id: string): {
381
+ function stubFlatTweenConversion(
382
+ id: string,
383
+ easeEach?: string,
384
+ ): {
382
385
  flat: GsapAnimation;
383
386
  converted: GsapAnimation;
384
387
  } {
@@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): {
393
396
  { percentage: 0, properties: { x: 0 } },
394
397
  { percentage: 100, properties: { x: 10 } },
395
398
  ],
399
+ ...(easeEach && { easeEach }),
396
400
  },
397
401
  });
398
402
  flatTweenResponses(flat, converted);
@@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => {
437
441
  expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
438
442
  });
439
443
 
444
+ it("keeps the converted tween's easeEach when it extends it to the playhead", async () => {
445
+ window.location.hash = "#/project/test-project";
446
+ usePlayerStore.setState({ currentTime: 3 });
447
+ const { flat } = stubFlatTweenConversion("flat-1", "none");
448
+ const commitMutation = vi.fn(async () => undefined);
449
+ const enable = renderEnableKeyframes({
450
+ domEditSelection: makeElementSelection(),
451
+ selectedGsapAnimations: [flat],
452
+ previewIframeRef: {
453
+ current: {
454
+ contentWindow: { gsap: { getProperty: () => 10 } },
455
+ } as unknown as HTMLIFrameElement,
456
+ },
457
+ handleGsapAddAnimation: vi.fn(),
458
+ handleGsapConvertToKeyframes: vi.fn(async () => undefined),
459
+ handleGsapRemoveKeyframe: vi.fn(),
460
+ commitMutation,
461
+ });
462
+
463
+ await act(async () => enable());
464
+
465
+ expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({
466
+ type: "replace-with-keyframes",
467
+ easeEach: "none",
468
+ });
469
+ });
470
+
440
471
  it("passes the convert coalesce key to an inside-range batch edit", async () => {
441
472
  window.location.hash = "#/project/test-project";
442
473
  usePlayerStore.setState({ currentTime: 1.5 });
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
15
15
  import {
16
16
  existingTweenTargetSelector,
17
17
  computeElementPercentage,
18
+ keyframeEases,
18
19
  KEYFRAME_PCT_MATCH,
19
20
  isInstantHold,
20
21
  resolveEditableTweenDuration,
@@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead(
266
267
  position: extended.position,
267
268
  duration: extended.duration,
268
269
  keyframes: extended.keyframes,
269
- ease: anim.ease,
270
+ ...keyframeEases(anim),
270
271
  },
271
272
  {
272
273
  label: "Add keyframe",
@@ -83,6 +83,25 @@ describe("external file change coordinator", () => {
83
83
  expect(captured.handle?.blocked).toBeNull();
84
84
  });
85
85
 
86
+ it("keeps one file-change subscription across re-renders and delivers to the latest callbacks", async () => {
87
+ const on = vi.fn((_event: string, next: HotHandler) => void (handler = next));
88
+ vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", { on, off: vi.fn() });
89
+ const { options } = await mountCoordinator();
90
+ on.mockClear();
91
+ const root = createRoot(document.createElement("div"));
92
+ roots.push(root);
93
+ function Probe({ onAccepted }: { onAccepted: () => void }) {
94
+ useExternalFileChangeCoordinator({ ...options, onAcceptedPersistedFileChange: onAccepted });
95
+ return null;
96
+ }
97
+ const latest = vi.fn();
98
+ await act(async () => root.render(<Probe onAccepted={vi.fn()} />));
99
+ await act(async () => root.render(<Probe onAccepted={latest} />));
100
+ await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
101
+ expect(on).toHaveBeenCalledOnce();
102
+ expect(latest).toHaveBeenCalledOnce();
103
+ });
104
+
86
105
  // The file tree (useFileTree) is only ever refreshed from Studio's own file
87
106
  // operations (create/delete/rename/upload) — never on an external change.
88
107
  // Without this call, an agent removing or replacing a composition updates
@@ -448,9 +448,11 @@ export function useExternalFileChangeCoordinator({
448
448
  [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
449
449
  );
450
450
 
451
+ const processChangeRef = useRef(processChange);
452
+ processChangeRef.current = processChange;
451
453
  useEffect(() => {
452
454
  // One decoder for all three transports; the rungs only choose the channel.
453
- const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery));
455
+ const handler = (delivery?: unknown) => processChangeRef.current(decodeFileChange(delivery));
454
456
  const adapter = testHotAdapter();
455
457
  if (adapter) {
456
458
  adapter.on("hf:file-change", handler);
@@ -461,7 +463,7 @@ export function useExternalFileChangeCoordinator({
461
463
  return () => import.meta.hot?.off?.("hf:file-change", handler);
462
464
  }
463
465
  return sseFileChangeChannel(handler);
464
- }, [processChange]);
466
+ }, []);
465
467
 
466
468
  const retry = useCallback(async () => {
467
469
  const current = blockedRef.current;
@@ -129,6 +129,7 @@ describe("useFileManager project ownership", () => {
129
129
 
130
130
  expect(fetchMock).toHaveBeenCalledWith(
131
131
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
132
+ { credentials: "omit" },
132
133
  );
133
134
  expect(fetchMock).toHaveBeenCalledWith(
134
135
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
@@ -136,14 +137,18 @@ describe("useFileManager project ownership", () => {
136
137
  );
137
138
  expect(fetchMock).toHaveBeenCalledWith(
138
139
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
140
+ { credentials: "omit" },
139
141
  );
140
142
  expect(fetchMock).toHaveBeenCalledWith(
141
143
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
142
144
  expect.objectContaining({ method: "PUT", body: "A_NEW" }),
143
145
  );
144
- expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
146
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html", {
147
+ credentials: "omit",
148
+ });
145
149
  expect(fetchMock).toHaveBeenCalledWith(
146
150
  "/api/projects/project-b%23fragment/files/index.html?optional=1",
151
+ { credentials: "omit" },
147
152
  );
148
153
 
149
154
  await act(async () => root.unmount());