@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.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 +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  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/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -7,6 +7,7 @@ import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/stud
7
7
  import { liveMarkupWithoutPreviewMarks } from "../utils/authoredSource";
8
8
  import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
9
9
  import { usePlayerStore } from "../player";
10
+ import { studioApiFetch } from "../utils/studioApiFetch";
10
11
 
11
12
  // ── Types ──
12
13
 
@@ -55,7 +56,7 @@ export function useAskAgentModal({
55
56
 
56
57
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
57
58
  try {
58
- const response = await fetch(
59
+ const response = await studioApiFetch(
59
60
  buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`),
60
61
  );
61
62
  if (!response.ok) return;
@@ -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)}`,
@@ -0,0 +1,335 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditing";
6
+ import type { PatchOperation } from "../utils/sourcePatcher";
7
+ import type { CutoverResult } from "../utils/sdkCutover";
8
+ import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
9
+ import {
10
+ DomEditPersistPreparedWriteError,
11
+ DomEditPersistUnresolvableError,
12
+ DomEditPersistUnsafeValueError,
13
+ } from "./domEditPersistFailure";
14
+ import type { PersistDomEditOperations } from "./domEditCommitTypes";
15
+ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
16
+ import { useDomEditPersist, type UseDomEditPersistParams } from "./useDomEditPersist";
17
+
18
+ const trackStudioEvent = vi.fn();
19
+ const reseekPreviewRuntime = vi.fn();
20
+ vi.mock("../utils/studioTelemetry", () => ({
21
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
22
+ }));
23
+ vi.mock("./timelineTrackVisibility", () => ({
24
+ reseekPreviewRuntime: (...args: unknown[]) => reseekPreviewRuntime(...args),
25
+ }));
26
+
27
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
28
+
29
+ const SOURCE = '<div data-hf-id="hf-card" style="color: red">Card</div>';
30
+ const PATCHED = '<div data-hf-id="hf-card" style="color: blue">Card</div>';
31
+ const OPERATIONS: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
32
+ const selection = {
33
+ hfId: "hf-card",
34
+ selector: '[data-hf-id="hf-card"]',
35
+ selectorIndex: 0,
36
+ sourceFile: "index.html",
37
+ } as DomEditSelection;
38
+
39
+ function stubServer(patchResponse: Record<string, unknown>) {
40
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
41
+ const url = requestUrl(input);
42
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: SOURCE });
43
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
44
+ return jsonResponse(patchResponse);
45
+ }
46
+ throw new Error(`Unexpected fetch: ${url}`);
47
+ });
48
+ vi.stubGlobal("fetch", fetchMock);
49
+ return fetchMock;
50
+ }
51
+
52
+ const CHANGED = { changed: true, content: PATCHED, path: "index.html", version: "v2" };
53
+ const PREPARED = PATCHED.replace("Card", "Card!");
54
+ const sdkResult = (result: CutoverResult) => vi.fn(async (): Promise<CutoverResult> => result);
55
+
56
+ const patchPosts = (fetchMock: ReturnType<typeof stubServer>) =>
57
+ fetchMock.mock.calls.filter(([input]) => requestUrl(input).includes("/patch-element/"));
58
+
59
+ let unmount: (() => void) | null = null;
60
+
61
+ function renderPersist(over: Partial<UseDomEditPersistParams> = {}) {
62
+ const params: UseDomEditPersistParams = {
63
+ activeCompPath: "index.html",
64
+ previewIframeRef: { current: null },
65
+ showToast: vi.fn(),
66
+ queueDomEditSave: (save) => save(),
67
+ writeProjectFile: vi.fn(async () => {}),
68
+ editHistory: { recordEdit: vi.fn(async () => {}) },
69
+ projectIdRef: { current: "p1" },
70
+ reloadPreview: vi.fn(),
71
+ forceReloadSdkSession: vi.fn(),
72
+ ...over,
73
+ };
74
+ const captured: { persist: PersistDomEditOperations | null } = { persist: null };
75
+ function Probe() {
76
+ captured.persist = useDomEditPersist(params);
77
+ return null;
78
+ }
79
+ const root = createRoot(document.createElement("div"));
80
+ act(() => root.render(createElement(Probe)));
81
+ unmount = () => act(() => root.unmount());
82
+ if (!captured.persist) throw new Error("hook did not initialize");
83
+ return { persist: captured.persist, params };
84
+ }
85
+
86
+ describe("useDomEditPersist", () => {
87
+ beforeEach(() => {
88
+ trackStudioEvent.mockReset();
89
+ reseekPreviewRuntime.mockReset();
90
+ });
91
+ afterEach(() => {
92
+ unmount?.();
93
+ unmount = null;
94
+ vi.unstubAllGlobals();
95
+ });
96
+
97
+ it("patches the source, records the edit and reloads the preview", async () => {
98
+ const fetchMock = stubServer({
99
+ changed: true,
100
+ content: PATCHED,
101
+ path: "index.html",
102
+ version: "v2",
103
+ });
104
+ const { persist, params } = renderPersist();
105
+
106
+ const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
107
+
108
+ expect(outcome).toEqual({ sourceFile: "index.html", version: "v2", changed: true });
109
+ const [, init] = patchPosts(fetchMock)[0]!;
110
+ expect(JSON.parse(String(init?.body))).toEqual({
111
+ target: { hfId: "hf-card", selector: '[data-hf-id="hf-card"]', selectorIndex: 0 },
112
+ operations: OPERATIONS,
113
+ });
114
+ expect(params.editHistory.recordEdit).toHaveBeenCalledWith({
115
+ label: "Recolor",
116
+ coalesceKey: undefined,
117
+ coalesceMs: undefined,
118
+ files: { "index.html": { before: SOURCE, after: PATCHED } },
119
+ });
120
+ expect(params.forceReloadSdkSession).toHaveBeenCalledTimes(1);
121
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
122
+ expect(reseekPreviewRuntime).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it("with skipRefresh reseeks the preview instead of reloading it", async () => {
126
+ stubServer({ changed: true, content: PATCHED, path: "index.html", version: "v2" });
127
+ const { persist, params } = renderPersist();
128
+
129
+ await persist(selection, OPERATIONS, { skipRefresh: true });
130
+
131
+ expect(params.reloadPreview).not.toHaveBeenCalled();
132
+ expect(reseekPreviewRuntime).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it("an unchanged patch records nothing and reports changed: false", async () => {
136
+ stubServer({ changed: false, matched: true, path: "index.html", version: "v1" });
137
+ const { persist, params } = renderPersist();
138
+
139
+ const outcome = await persist(selection, OPERATIONS);
140
+
141
+ expect(outcome).toEqual({ sourceFile: "index.html", version: "v1", changed: false });
142
+ expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
143
+ expect(params.reloadPreview).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("an unmatched target throws and is reported once per target", async () => {
147
+ stubServer({ changed: false, matched: false });
148
+ const { persist } = renderPersist();
149
+
150
+ await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
151
+ DomEditPersistUnresolvableError,
152
+ );
153
+ await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
154
+ DomEditPersistUnresolvableError,
155
+ );
156
+
157
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
158
+ expect(trackStudioEvent).toHaveBeenCalledWith(
159
+ "save_skipped_unresolvable",
160
+ expect.objectContaining({ target_selector: '[data-hf-id="hf-card"]' }),
161
+ );
162
+ });
163
+
164
+ it("refuses a non-finite layout value before any request, with a toast", async () => {
165
+ const fetchMock = stubServer({ changed: true });
166
+ const { persist, params } = renderPersist();
167
+
168
+ await expect(
169
+ persist({ ...selection, selectorIndex: Number.NaN }, OPERATIONS),
170
+ ).rejects.toBeInstanceOf(DomEditPersistUnsafeValueError);
171
+
172
+ expect(fetchMock).not.toHaveBeenCalled();
173
+ expect(params.showToast).toHaveBeenCalledWith(
174
+ "Couldn't save edit because it contains invalid layout values",
175
+ "error",
176
+ );
177
+ });
178
+
179
+ it("a committed SDK cutover skips the server patch and the reload", async () => {
180
+ const fetchMock = stubServer({ changed: true });
181
+ const onTrySdkPersist = vi.fn(
182
+ async (): Promise<CutoverResult> => ({
183
+ status: "committed",
184
+ version: "sdk-3",
185
+ before: SOURCE,
186
+ after: PATCHED,
187
+ }),
188
+ );
189
+ const { persist, params } = renderPersist({ onTrySdkPersist });
190
+
191
+ const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
192
+
193
+ expect(outcome).toEqual({ sourceFile: "index.html", version: "sdk-3", changed: true });
194
+ expect(onTrySdkPersist).toHaveBeenCalledWith(selection, OPERATIONS, SOURCE, "index.html", {
195
+ label: "Recolor",
196
+ coalesceKey: undefined,
197
+ skipRefresh: undefined,
198
+ });
199
+ expect(patchPosts(fetchMock)).toHaveLength(0);
200
+ expect(params.reloadPreview).not.toHaveBeenCalled();
201
+ expect(params.forceReloadSdkSession).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("records the edit once, and reloads only after the record has settled", async () => {
205
+ stubServer(CHANGED);
206
+ let finishRecord = () => {};
207
+ const recordEdit = vi.fn(
208
+ () =>
209
+ new Promise<void>((resolve) => {
210
+ finishRecord = resolve;
211
+ }),
212
+ );
213
+ const { persist, params } = renderPersist({ editHistory: { recordEdit } });
214
+
215
+ const saving = persist(selection, OPERATIONS);
216
+ await vi.waitFor(() => expect(recordEdit).toHaveBeenCalledTimes(1));
217
+ expect(params.reloadPreview).not.toHaveBeenCalled();
218
+ finishRecord();
219
+ await saving;
220
+
221
+ expect(recordEdit).toHaveBeenCalledTimes(1);
222
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
223
+ });
224
+
225
+ it("writes prepared content after the patch and records it as the saved file", async () => {
226
+ stubServer(CHANGED);
227
+ const { persist, params } = renderPersist();
228
+
229
+ const outcome = await persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
230
+
231
+ expect(params.writeProjectFile).toHaveBeenCalledWith("index.html", PREPARED, PATCHED);
232
+ expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
233
+ expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PREPARED } } }),
234
+ );
235
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
236
+ expect(outcome).toBeUndefined();
237
+ });
238
+
239
+ it("a failed prepared write keeps the patch, reloads, then rejects", async () => {
240
+ stubServer(CHANGED);
241
+ const { persist, params } = renderPersist({
242
+ writeProjectFile: vi.fn(async () => {
243
+ throw new Error("disk full");
244
+ }),
245
+ });
246
+
247
+ const saving = persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
248
+
249
+ await expect(saving).rejects.toBeInstanceOf(DomEditPersistPreparedWriteError);
250
+ expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
251
+ expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PATCHED } } }),
252
+ );
253
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
254
+ expect(params.showToast).toHaveBeenCalledWith(
255
+ "Saved, but couldn't finish updating index.html: disk full",
256
+ "error",
257
+ );
258
+ });
259
+
260
+ it("a declined SDK cutover falls back to the server patch", async () => {
261
+ const fetchMock = stubServer(CHANGED);
262
+ const onTrySdkPersist = sdkResult({ status: "declined", reason: "unsupported" });
263
+ const { persist, params } = renderPersist({ onTrySdkPersist });
264
+
265
+ await persist(selection, OPERATIONS);
266
+
267
+ expect(onTrySdkPersist).toHaveBeenCalledTimes(1);
268
+ expect(patchPosts(fetchMock)).toHaveLength(1);
269
+ expect(params.editHistory.recordEdit).toHaveBeenCalledTimes(1);
270
+ });
271
+
272
+ it("a failed SDK cutover rethrows without a server patch", async () => {
273
+ const fetchMock = stubServer(CHANGED);
274
+ const error = new Error("sdk broke");
275
+ const { persist } = renderPersist({ onTrySdkPersist: sdkResult({ status: "failed", error }) });
276
+
277
+ await expect(persist(selection, OPERATIONS)).rejects.toBe(error);
278
+ expect(patchPosts(fetchMock)).toHaveLength(0);
279
+ });
280
+
281
+ it("an imported font or prepared content skips the SDK and goes to the server", async () => {
282
+ const fetchMock = stubServer(CHANGED);
283
+ const onTrySdkPersist = sdkResult({ status: "declined", reason: "unused" });
284
+ const { persist } = renderPersist({ onTrySdkPersist });
285
+ const importedFont = { family: "Inter", path: "fonts/inter.woff2", url: "/fonts/inter.woff2" };
286
+
287
+ await persist(selection, OPERATIONS, { importedFont });
288
+ await persist(selection, OPERATIONS, { prepareContent: (html) => html });
289
+
290
+ expect(onTrySdkPersist).not.toHaveBeenCalled();
291
+ const [[, fontInit]] = patchPosts(fetchMock);
292
+ expect(JSON.parse(String(fontInit?.body)).fontFaceCss).toContain("Inter");
293
+ expect(patchPosts(fetchMock)).toHaveLength(2);
294
+ });
295
+
296
+ it("counts a save on the edited element's document until it settles", async () => {
297
+ stubServer(CHANGED);
298
+ const element = document.createElement("div");
299
+ const { persist } = renderPersist();
300
+ const before = studioManualEditSavesIn(document);
301
+
302
+ const saving = persist({ ...selection, element }, OPERATIONS);
303
+ expect(studioManualEditSavesIn(document)).toBe(before + 1);
304
+ await saving;
305
+
306
+ expect(studioManualEditSavesIn(document)).toBe(before + 2);
307
+ });
308
+
309
+ it("does nothing when shouldSave says no", async () => {
310
+ const fetchMock = stubServer({ changed: true });
311
+ const { persist } = renderPersist();
312
+
313
+ await expect(persist(selection, OPERATIONS, { shouldSave: () => false })).resolves.toBe(
314
+ undefined,
315
+ );
316
+ expect(fetchMock).not.toHaveBeenCalled();
317
+ });
318
+
319
+ it("refuses to save once the active project has changed", async () => {
320
+ stubServer({ changed: true });
321
+ const projectIdRef = { current: "p1" as string | null };
322
+ const { persist, params } = renderPersist({
323
+ projectIdRef,
324
+ queueDomEditSave: async (save) => {
325
+ projectIdRef.current = "p2";
326
+ return save();
327
+ },
328
+ });
329
+
330
+ await expect(persist(selection, OPERATIONS)).rejects.toThrow(
331
+ "Active project changed before the edit could be saved",
332
+ );
333
+ expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
334
+ });
335
+ });
@@ -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,
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act, createElement, useRef } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
5
5
  import {
6
6
  useDomGeometryCommit,
7
7
  usePlayerStore,
@@ -108,6 +108,10 @@ afterEach(() => {
108
108
  });
109
109
 
110
110
  describe("useDomGeometryCommit, from the package entry", () => {
111
+ it("takes the host's own preview reload, with no fallback", () => {
112
+ expectTypeOf<UseDomGeometryCommitOptions["reloadPreview"]>().toEqualTypeOf<() => void>();
113
+ });
114
+
111
115
  it("plugs into DomEditOverlay and saves a move as one GSAP write and one undo step", async () => {
112
116
  const mutations = stubServer();
113
117
  const { element, recordEdit, hook, unmount } = renderHost();
@@ -26,8 +26,8 @@ import { useMountEffect } from "./useMountEffect";
26
26
  * mounts; drain `waitForPendingSaves` before switching projects.
27
27
  */
28
28
  export interface UseDomGeometryCommitOptions extends UseDomStyleCommitOptions {
29
- /** Called when a save cannot patch the preview in place; defaults to reloading the iframe. */
30
- reloadPreview?: () => void;
29
+ /** Reloads the host's preview when a save cannot patch it in place. */
30
+ reloadPreview: () => void;
31
31
  }
32
32
 
33
33
  export interface DomGeometryCommits {
@@ -42,6 +42,7 @@ export interface DomGeometryCommits {
42
42
  next: { width: number; height: number },
43
43
  offset?: { x: number; y: number },
44
44
  restore?: () => void,
45
+ route?: { plainTranslate: boolean },
45
46
  ) => Promise<DomEditCommitOutcome>;
46
47
  commitRotation: (
47
48
  selection: DomEditSelection,
@@ -70,17 +71,13 @@ export function useDomGeometryCommit({
70
71
  projectIdRef.current = projectId;
71
72
  const pending = useRef(new Set<Promise<GeometryCommitResult | undefined>>()).current;
72
73
  const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
73
- const reload = useCallback(
74
- () => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
75
- [reloadPreview, iframeRef],
76
- );
77
74
  const { bump: bumpGsapCache } = useGsapCacheVersion();
78
75
  const gsap = useGsapScriptCommits({
79
76
  projectIdRef,
80
77
  activeCompPath,
81
78
  previewIframeRef: iframeRef,
82
79
  editHistory,
83
- reloadPreview: reload,
80
+ reloadPreview,
84
81
  onCacheInvalidate: bumpGsapCache,
85
82
  showToast,
86
83
  writeProjectFile,
@@ -128,6 +125,7 @@ export function useDomGeometryCommit({
128
125
  domEditSelection: null,
129
126
  selectedGsapAnimations: NO_SELECTED_ANIMATIONS,
130
127
  gsapCommitMutation: gsap.commitMutation,
128
+ activeCompPath,
131
129
  previewIframeRef: iframeRef,
132
130
  showToast,
133
131
  bumpGsapCache,
@@ -173,8 +171,8 @@ export function useDomGeometryCommit({
173
171
  saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)),
174
172
  commitGroupPathOffset: (updates) =>
175
173
  saved(() => handleGsapAwareGroupPathOffsetCommit(updates)),
176
- commitBoxSize: (selection, next, offset, restore) =>
177
- saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore),
174
+ commitBoxSize: (selection, next, offset, restore, route) =>
175
+ saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore, route), restore),
178
176
  commitRotation: (selection, next) =>
179
177
  saved(() => handleGsapAwareRotationCommit(selection, next)),
180
178
  waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop),