@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
@@ -8,6 +8,7 @@ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordina
8
8
  import { useFileTree } from "./useFileTree";
9
9
  import { useEditorSave } from "./useEditorSave";
10
10
  import { useProjectFileWriter } from "./useProjectFileWriter";
11
+ import { studioApiFetch } from "../utils/studioApiFetch";
11
12
 
12
13
  // ── Types ──
13
14
 
@@ -133,7 +134,7 @@ export function useFileManager({
133
134
  setEditingFile({ path, content: null });
134
135
  return;
135
136
  }
136
- fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
137
+ studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
137
138
  .then((r) => {
138
139
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
139
140
  return r.json();
@@ -167,9 +168,12 @@ export function useFileManager({
167
168
  const requestId = ++revealRequestIdRef.current;
168
169
  const controller = new AbortController();
169
170
  revealAbortRef.current = controller;
170
- fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
171
- signal: controller.signal,
172
- })
171
+ studioApiFetch(
172
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`,
173
+ {
174
+ signal: controller.signal,
175
+ },
176
+ )
173
177
  .then((r) => r.json())
174
178
  .then((data: { content?: string; version?: string }) => {
175
179
  if (requestId !== revealRequestIdRef.current) return;
@@ -200,7 +204,7 @@ export function useFileManager({
200
204
 
201
205
  const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
202
206
  try {
203
- const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
207
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
204
208
  method: "POST",
205
209
  body: formData,
206
210
  });
@@ -246,7 +250,7 @@ export function useFileManager({
246
250
  content =
247
251
  '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
248
252
  }
249
- const res = await fetch(
253
+ const res = await studioApiFetch(
250
254
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
251
255
  {
252
256
  method: "POST",
@@ -270,7 +274,7 @@ export function useFileManager({
270
274
  async (path: string) => {
271
275
  const pid = projectIdRef.current;
272
276
  if (!pid) return;
273
- const res = await fetch(
277
+ const res = await studioApiFetch(
274
278
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
275
279
  {
276
280
  method: "POST",
@@ -293,7 +297,7 @@ export function useFileManager({
293
297
  async (path: string) => {
294
298
  const pid = projectIdRef.current;
295
299
  if (!pid) return;
296
- const res = await fetch(
300
+ const res = await studioApiFetch(
297
301
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
298
302
  {
299
303
  method: "DELETE",
@@ -315,7 +319,7 @@ export function useFileManager({
315
319
  async (oldPath: string, newPath: string) => {
316
320
  const pid = projectIdRef.current;
317
321
  if (!pid) return;
318
- const res = await fetch(
322
+ const res = await studioApiFetch(
319
323
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
320
324
  {
321
325
  method: "PATCH",
@@ -342,7 +346,7 @@ export function useFileManager({
342
346
  async (path: string) => {
343
347
  const pid = projectIdRef.current;
344
348
  if (!pid) return;
345
- const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
349
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
346
350
  method: "POST",
347
351
  headers: { "Content-Type": "application/json" },
348
352
  body: JSON.stringify({ path }),
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { FONT_EXT } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { captureProjectProvenance } from "../components/feedback/projectProvenance";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface UseFileTreeOptions {
8
9
  projectId: string | null;
@@ -38,7 +39,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
38
39
  if (!projectId) return;
39
40
  let cancelled = false;
40
41
  setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
41
- fetch(buildProjectApiPath(projectId))
42
+ studioApiFetch(buildProjectApiPath(projectId))
42
43
  // An unresolvable project answers 404 with a JSON body, so without this
43
44
  // the error path parsed cleanly and the success branch below recorded an
44
45
  // empty tree — and an empty provenance snapshot — for a project that was
@@ -84,7 +85,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
84
85
  refreshAbortRef.current = controller;
85
86
  let data: { files?: string[]; compositions?: string[] };
86
87
  try {
87
- const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
88
+ const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
88
89
  data = await res.json();
89
90
  } catch (err) {
90
91
  if (err instanceof DOMException && err.name === "AbortError") return;
@@ -2,6 +2,7 @@ import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { liveTime, usePlayerStore } from "../player";
4
4
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
5
6
 
6
7
  interface UseFrameCaptureParams {
7
8
  projectId: string | null;
@@ -67,7 +68,10 @@ export function useFrameCapture({
67
68
  const controller = new AbortController();
68
69
  const timeout = setTimeout(() => controller.abort(), 30000);
69
70
  try {
70
- const response = await fetch(href, { cache: "no-store", signal: controller.signal });
71
+ const response = await studioApiFetch(href, {
72
+ cache: "no-store",
73
+ signal: controller.signal,
74
+ });
71
75
  clearTimeout(timeout);
72
76
  if (!response.ok) {
73
77
  let msg = `Capture failed (${response.status})`;
@@ -6,6 +6,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
6
6
  import { markStudioWriteToken } from "../utils/studioFileVersion";
7
7
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
12
 
@@ -31,7 +32,7 @@ function errorOf(value: unknown): string | null {
31
32
  }
32
33
 
33
34
  async function readVersion(projectId: string, path: string): Promise<string> {
34
- const response = await fetch(
35
+ const response = await studioApiFetch(
35
36
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
36
37
  );
37
38
  const body: unknown = await response.json().catch(() => null);
@@ -53,7 +54,7 @@ export async function requestFreezeFrame(input: {
53
54
  const expectedVersion = await readVersion(input.projectId, input.path);
54
55
  const transactionToken = `freeze:${crypto.randomUUID()}`;
55
56
  markStudioWriteToken(transactionToken);
56
- const response = await fetch(
57
+ const response = await studioApiFetch(
57
58
  buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
58
59
  {
59
60
  method: "POST",
@@ -16,6 +16,7 @@ import {
16
16
  type ElementMatchSelection,
17
17
  } from "../utils/studioHelpers";
18
18
  import type { TimelineElement } from "../player";
19
+ import { studioApiFetch } from "../utils/studioApiFetch";
19
20
 
20
21
  interface UseGroupCommitsParams extends DomEditCommitBaseParams {
21
22
  /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
@@ -132,7 +133,7 @@ async function commitStructuralMutation(
132
133
  writeFile: deps.writeProjectFile,
133
134
  recordEdit: deps.editHistory.recordEdit,
134
135
  rewrite: async (originalContent) => {
135
- const mutateResponse = await fetch(
136
+ const mutateResponse = await studioApiFetch(
136
137
  buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
137
138
  {
138
139
  method: "POST",
@@ -0,0 +1,160 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
8
+ import { usePlayerStore } from "../player/store/playerStore";
9
+ import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
10
+ import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
11
+ import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
12
+
13
+ vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
15
+ trackStudioEditBlocked: vi.fn(),
16
+ trackStudioSaveFailure: vi.fn(),
17
+ }));
18
+
19
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ beforeEach(() => {
22
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 1, activeKeyframePct: null });
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.clearAllMocks();
27
+ document.body.innerHTML = "";
28
+ Reflect.deleteProperty(window, "__timelines");
29
+ });
30
+
31
+ const positionTween = (id: string, extra: Partial<GsapAnimation> = {}) =>
32
+ ({
33
+ id: `#${id}-to-0-position`,
34
+ targetSelector: `#${id}`,
35
+ propertyGroup: "position",
36
+ method: "to",
37
+ properties: { x: 100 },
38
+ position: 0,
39
+ resolvedStart: 0,
40
+ duration: 2,
41
+ ease: "none",
42
+ ...extra,
43
+ }) as unknown as GsapAnimation;
44
+
45
+ /** Three members GSAP is visibly moving, one `to` tween each in the file. */
46
+ function mountGroup(animations: GsapAnimation[]) {
47
+ const elements = ["a", "b", "c"].map((id) => {
48
+ const el = Object.assign(document.createElement("div"), { id });
49
+ document.body.append(el);
50
+ return el;
51
+ });
52
+ const live = elements.map((el) => ({
53
+ targets: () => [el],
54
+ vars: { x: 100, duration: 2 },
55
+ duration: () => 2,
56
+ startTime: () => 0,
57
+ }));
58
+ const timelines = { root: { getChildren: () => live, duration: () => 2 } };
59
+ Reflect.set(window, "__timelines", timelines);
60
+ const iframe = {
61
+ contentWindow: { gsap: { getProperty: () => 0, set: vi.fn() }, __timelines: timelines },
62
+ contentDocument: document,
63
+ } as unknown as HTMLIFrameElement;
64
+ const commitMutation = Object.assign(vi.fn().mockResolvedValue(undefined), {
65
+ batch: vi.fn().mockResolvedValue(undefined),
66
+ });
67
+ const showToast = vi.fn();
68
+ const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
69
+ const { editing, root } = mountGsapAwareEditing({
70
+ gsapCommitMutation: commitMutation,
71
+ activeCompPath: "index.html",
72
+ previewIframeRef: { current: iframe },
73
+ showToast,
74
+ makeFetchFallback: () => async () => animations,
75
+ stageElementPositionOffset,
76
+ });
77
+ const groupCommit = (updates: DomEditGroupPathOffsetCommit[]) =>
78
+ editing().handleGsapAwareGroupPathOffsetCommit(updates);
79
+ const updates = elements.map((element) => ({
80
+ selection: {
81
+ element,
82
+ id: element.id,
83
+ selector: `#${element.id}`,
84
+ } as unknown as DomEditSelection,
85
+ next: { x: 30, y: 0 },
86
+ }));
87
+ const written = () => [
88
+ ...commitMutation.mock.calls.map((call) => call[1]),
89
+ ...commitMutation.batch.mock.calls.flatMap(([calls]) =>
90
+ calls.map((c: { mutation: unknown }) => c.mutation),
91
+ ),
92
+ ];
93
+ return { elements, iframe, updates, groupCommit, commitMutation, written, showToast, root };
94
+ }
95
+
96
+ describe("a group drag plans every member before its first write", () => {
97
+ it("refuses the whole group when one member's tween cannot take a keyframe", async () => {
98
+ const h = mountGroup([
99
+ positionTween("a"),
100
+ positionTween("b", { ease: undefined }),
101
+ positionTween("c"),
102
+ ]);
103
+ const styles = h.elements.map((el) => el.getAttribute("style"));
104
+
105
+ await expect(h.groupCommit(h.updates)).rejects.toMatchObject({
106
+ name: "GsapEditBlockedError",
107
+ reason: "keyframes-uneditable",
108
+ detail: "unknown-ease",
109
+ });
110
+
111
+ expect(h.written()).toEqual([]);
112
+ expect(h.commitMutation.batch).not.toHaveBeenCalled();
113
+ const gsap = (h.iframe.contentWindow as unknown as { gsap: { set: unknown } }).gsap;
114
+ expect(gsap.set).not.toHaveBeenCalled();
115
+ expect(h.elements.map((el) => el.getAttribute("style"))).toEqual(styles);
116
+ expect(h.showToast).toHaveBeenCalledTimes(1);
117
+ expect(h.showToast).toHaveBeenCalledWith(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"], "error");
118
+ expect(trackStudioEditBlocked).toHaveBeenCalledTimes(1);
119
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "b" }));
120
+ act(() => h.root.unmount());
121
+ });
122
+
123
+ it("refuses members animated in two files, since the batch writes one file", async () => {
124
+ const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
125
+ const updates = h.updates.map((u, i) => ({
126
+ ...u,
127
+ selection: { ...u.selection, sourceFile: i === 2 ? "scenes/intro.html" : "index.html" },
128
+ }));
129
+
130
+ await expect(h.groupCommit(updates)).rejects.toMatchObject({ reason: "mixed-files" });
131
+
132
+ expect(h.written()).toEqual([]);
133
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "c" }));
134
+ act(() => h.root.unmount());
135
+ });
136
+
137
+ it("writes a group whose members name the open file or leave it implied", async () => {
138
+ const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
139
+ const updates = h.updates.map((u, i) => ({
140
+ ...u,
141
+ selection: { ...u.selection, sourceFile: i === 0 ? "" : "index.html" },
142
+ }));
143
+
144
+ await h.groupCommit(updates);
145
+
146
+ expect(h.written()).toHaveLength(3);
147
+ act(() => h.root.unmount());
148
+ });
149
+
150
+ it("writes every member when each plan holds", async () => {
151
+ const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
152
+
153
+ await h.groupCommit(h.updates);
154
+
155
+ const ids = h.written().map((m) => (m as { animationId?: string }).animationId);
156
+ expect(ids).toEqual(["#a-to-0-position", "#b-to-0-position", "#c-to-0-position"]);
157
+ expect(h.showToast).not.toHaveBeenCalled();
158
+ act(() => h.root.unmount());
159
+ });
160
+ });
@@ -119,18 +119,21 @@ function mountResizeHandler(
119
119
  type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
120
120
 
121
121
  function mountGroupHandler({
122
- gsapCommitMutation,
123
- makeFetchFallback,
122
+ gsapCommitMutation = vi.fn().mockResolvedValue(undefined),
123
+ makeFetchFallback = () => vi.fn().mockResolvedValue([]),
124
124
  trackGsapInteractionFailure = vi.fn(),
125
125
  stageElementPositionOffset = vi.fn(),
126
126
  handleDomRotationCommit = vi.fn(),
127
- }: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
128
- Partial<
129
- Pick<
130
- AwareEditingParams,
131
- "trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit"
132
- >
133
- >) {
127
+ }: Partial<
128
+ Pick<
129
+ AwareEditingParams,
130
+ | "gsapCommitMutation"
131
+ | "makeFetchFallback"
132
+ | "trackGsapInteractionFailure"
133
+ | "stageElementPositionOffset"
134
+ | "handleDomRotationCommit"
135
+ >
136
+ > = {}) {
134
137
  let groupCommit!: (
135
138
  updates: DomEditGroupPathOffsetCommit[],
136
139
  ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
@@ -216,8 +219,6 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
216
219
  const save = vi.fn().mockResolvedValue(undefined);
217
220
  const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
218
221
  const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({
219
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
220
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
221
222
  stageElementPositionOffset,
222
223
  });
223
224
  const box = {
@@ -236,11 +237,7 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
236
237
  describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
237
238
  it("writes no member when one CSS-route member has been folded since the press", async () => {
238
239
  const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
239
- const { groupCommit, root } = mountGroupHandler({
240
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
241
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
242
- stageElementPositionOffset,
243
- });
240
+ const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset });
244
241
  const card = {
245
242
  element: document.createElement("div"),
246
243
  id: "card",
@@ -268,7 +265,6 @@ describe("useGsapAwareEditing rotation routing", () => {
268
265
  const gsapCommitMutation = vi.fn();
269
266
  const h = mountGroupHandler({
270
267
  gsapCommitMutation,
271
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
272
268
  handleDomRotationCommit,
273
269
  });
274
270
  const box = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
@@ -332,7 +328,6 @@ describe("useGsapAwareEditing shared-tween moves", () => {
332
328
  const commitMutation = vi.fn();
333
329
  const { pathOffsetCommit, root } = mountGroupHandler({
334
330
  gsapCommitMutation: commitMutation,
335
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
336
331
  stageElementPositionOffset,
337
332
  });
338
333
  const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
@@ -351,11 +346,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
351
346
  });
352
347
  const save = vi.fn().mockResolvedValue(undefined);
353
348
  const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
354
- const { groupCommit, root } = mountGroupHandler({
355
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
356
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
357
- stageElementPositionOffset,
358
- });
349
+ const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset });
359
350
  const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
360
351
  const box = { element: gsapPositioned("div"), id: "box", selector: "#box" };
361
352
  const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
@@ -406,6 +397,7 @@ describe("useGsapAwareEditing anchored resize", () => {
406
397
  null,
407
398
  expect.any(Function),
408
399
  expect.any(Function),
400
+ undefined,
409
401
  );
410
402
  act(() => h.root.unmount());
411
403
  });
@@ -658,7 +650,6 @@ describe("useGsapAwareEditing anchored resize", () => {
658
650
  );
659
651
  const { groupCommit, root } = mountGroupHandler({
660
652
  gsapCommitMutation: commitMutation,
661
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
662
653
  });
663
654
  const updates = [
664
655
  {
@@ -759,11 +750,7 @@ describe("useGsapAwareEditing anchored resize", () => {
759
750
  mocks.drag.mockImplementation(async (selection) => {
760
751
  throw selection.id === "a" ? failures[0] : failures[1];
761
752
  });
762
- const { groupCommit, root } = mountGroupHandler({
763
- gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
764
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
765
- trackGsapInteractionFailure,
766
- });
753
+ const { groupCommit, root } = mountGroupHandler({ trackGsapInteractionFailure });
767
754
  const updates = [
768
755
  {
769
756
  selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
@@ -782,6 +769,7 @@ describe("useGsapAwareEditing anchored resize", () => {
782
769
  updates[0]?.selection,
783
770
  "drag",
784
771
  "Move animated layer (group)",
772
+ true,
785
773
  );
786
774
  mocks.drag.mockReset();
787
775
  if (priorDragImplementation) mocks.drag.mockImplementation(priorDragImplementation);
@@ -0,0 +1,42 @@
1
+ import { vi } from "vitest";
2
+ import { mountReactHarness } from "./domSelectionTestHarness";
3
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
4
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
5
+
6
+ type Params = Parameters<typeof useGsapAwareEditing>[0];
7
+
8
+ /**
9
+ * Mounts useGsapAwareEditing with no-op callbacks for every param not given, and the real
10
+ * failure telemetry toasting through `showToast`. `editing()` reads the latest render.
11
+ */
12
+ export function mountGsapAwareEditing(
13
+ params: Partial<Omit<Params, "trackGsapInteractionFailure">> & Pick<Params, "showToast">,
14
+ ) {
15
+ let editing!: ReturnType<typeof useGsapAwareEditing>;
16
+ function Harness() {
17
+ editing = useGsapAwareEditing({
18
+ domEditSelection: null,
19
+ selectedGsapAnimations: [],
20
+ gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
21
+ previewIframeRef: { current: null },
22
+ bumpGsapCache: vi.fn(),
23
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
24
+ stageElementPositionOffset: vi.fn(),
25
+ handleDomBoxSizeCommit: vi.fn(),
26
+ handleDomRotationCommit: vi.fn(),
27
+ commitPositionPatchToHtml: vi.fn(),
28
+ addGsapAnimation: vi.fn(),
29
+ convertToKeyframes: vi.fn(),
30
+ setArcPath: vi.fn(),
31
+ updateArcSegment: vi.fn(),
32
+ ...params,
33
+ trackGsapInteractionFailure: useGsapInteractionFailureTelemetry(
34
+ "index.html",
35
+ params.showToast,
36
+ ),
37
+ });
38
+ return null;
39
+ }
40
+ const root = mountReactHarness(<Harness />);
41
+ return { editing: () => editing, root };
42
+ }
@@ -49,6 +49,7 @@ export interface UseGsapAwareEditingParams {
49
49
  domEditSelection: DomEditSelection | null;
50
50
  selectedGsapAnimations: GsapAnimation[];
51
51
  gsapCommitMutation: CommitMutation | null;
52
+ activeCompPath?: string | null;
52
53
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
53
54
  showToast: (message: string, tone?: "error" | "info") => void;
54
55
  bumpGsapCache: () => void;
@@ -61,6 +62,7 @@ export interface UseGsapAwareEditingParams {
61
62
  selection: DomEditSelection | null,
62
63
  mutationType: string,
63
64
  label: string,
65
+ toast?: boolean,
64
66
  ) => void;
65
67
  // DOM fallbacks (from useDomEditCommits)
66
68
  stageElementPositionOffset: (
@@ -117,6 +119,7 @@ export function useGsapAwareEditing({
117
119
  domEditSelection,
118
120
  selectedGsapAnimations,
119
121
  gsapCommitMutation,
122
+ activeCompPath,
120
123
  previewIframeRef,
121
124
  showToast,
122
125
  bumpGsapCache,
@@ -190,6 +193,7 @@ export function useGsapAwareEditing({
190
193
 
191
194
  const handleGsapAwareGroupPathOffsetCommit = useGsapAwareGroupMove({
192
195
  gsapCommitMutation,
196
+ activeCompPath,
193
197
  previewIframeRef,
194
198
  makeFetchFallback,
195
199
  trackGsapInteractionFailure,
@@ -285,6 +289,7 @@ export function useGsapAwareEditing({
285
289
  previewIframeRef.current,
286
290
  commitMutation,
287
291
  makeFetchFallback(selection),
292
+ offset,
288
293
  );
289
294
  assertGsapEditPersisted(outcome);
290
295
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
@@ -9,24 +9,14 @@ import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
9
9
  import { refuseGsapTakeover } from "./elementOffsetStager";
10
10
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
11
  import { assertGsapEditPersisted } from "./gsapEditOutcome";
12
+ import { firstPreflightFailure } from "./gsapGroupPreflight";
12
13
 
13
14
  // A distinct key keeps consecutive group drags in separate undo entries.
14
15
  let groupDragCommitCounter = 0;
15
16
 
16
- function firstPreflightFailure(
17
- results: PromiseSettledResult<void>[],
18
- updates: DomEditGroupPathOffsetCommit[],
19
- ): { error: unknown; selection: DomEditSelection } | null {
20
- for (const [index, result] of results.entries()) {
21
- if (result.status !== "rejected") continue;
22
- const selection = updates[index]?.selection;
23
- if (selection) return { error: result.reason, selection };
24
- }
25
- return null;
26
- }
27
-
28
17
  export function useGsapAwareGroupMove({
29
18
  gsapCommitMutation,
19
+ activeCompPath,
30
20
  previewIframeRef,
31
21
  makeFetchFallback,
32
22
  trackGsapInteractionFailure,
@@ -35,6 +25,7 @@ export function useGsapAwareGroupMove({
35
25
  }: Pick<
36
26
  UseGsapAwareEditingParams,
37
27
  | "gsapCommitMutation"
28
+ | "activeCompPath"
38
29
  | "previewIframeRef"
39
30
  | "makeFetchFallback"
40
31
  | "trackGsapInteractionFailure"
@@ -44,10 +35,12 @@ export function useGsapAwareGroupMove({
44
35
  const handleGsapAwareGroupPathOffsetCommit = useCallback(
45
36
  async (
46
37
  updates: DomEditGroupPathOffsetCommit[],
38
+ options: { refusalToast?: boolean } = {},
47
39
  ): Promise<import("../utils/previewFeatureUsage").GeometryCommitResult> => {
48
40
  const writes = observeGsapGesture(gsapCommitMutation);
49
41
  const writer = writes.commit;
50
42
  if (!writer) return { ok: true, changed: false };
43
+ const toastRefusal = options.refusalToast !== false;
51
44
  // One coalesce key across slow writes keeps the group drag in one undo entry.
52
45
  const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
53
46
  // Hold the render until every member is saved, or unwritten members snap back.
@@ -87,16 +80,16 @@ export function useGsapAwareGroupMove({
87
80
  // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
88
81
  // write nothing and share one in-flight parse per file, so they run together.
89
82
  const preflightResults = await Promise.allSettled(
90
- updates.map(async ({ selection, plainTranslate }) => {
83
+ updates.map(async ({ selection, next, plainTranslate }) => {
91
84
  if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
92
- refuseGsapTakeover(selection.element, showToast);
85
+ refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
93
86
  return void offsetMembers.set(selection, true);
94
87
  }
95
88
  const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
96
89
  preflightAnimations.set(selection, animations);
97
90
  const outcome = await tryGsapDragIntercept(
98
91
  selection,
99
- { x: 0, y: 0 },
92
+ next,
100
93
  animations,
101
94
  previewIframeRef.current,
102
95
  coalescedCommit,
@@ -107,13 +100,19 @@ export function useGsapAwareGroupMove({
107
100
  assertGsapEditPersisted(outcome);
108
101
  }),
109
102
  );
110
- const preflightFailure = firstPreflightFailure(preflightResults, updates);
103
+ const preflightFailure = firstPreflightFailure(
104
+ preflightResults,
105
+ updates,
106
+ offsetMembers,
107
+ activeCompPath,
108
+ );
111
109
  if (preflightFailure) {
112
110
  trackGsapInteractionFailure(
113
111
  preflightFailure.error,
114
112
  preflightFailure.selection,
115
113
  "drag",
116
114
  "Move animated layer (group)",
115
+ toastRefusal,
117
116
  );
118
117
  throw preflightFailure.error;
119
118
  }
@@ -167,6 +166,7 @@ export function useGsapAwareGroupMove({
167
166
  },
168
167
  [
169
168
  gsapCommitMutation,
169
+ activeCompPath,
170
170
  previewIframeRef,
171
171
  makeFetchFallback,
172
172
  trackGsapInteractionFailure,
@@ -4,8 +4,8 @@ import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioS
4
4
  import { isGsapEditBlockedError } from "./gsapEditOutcome";
5
5
  import { wasAlreadyToasted } from "./domEditPersistFailure";
6
6
 
7
- function failureToast(error: unknown): string | null {
8
- if (wasAlreadyToasted(error)) return null;
7
+ function failureToast(error: unknown, toast: boolean): string | null {
8
+ if (!toast || wasAlreadyToasted(error)) return null;
9
9
  return isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.";
10
10
  }
11
11
 
@@ -14,7 +14,13 @@ export function useGsapInteractionFailureTelemetry(
14
14
  showToast: (message: string, tone?: "error" | "info") => void,
15
15
  ) {
16
16
  return useCallback(
17
- (error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
17
+ (
18
+ error: unknown,
19
+ selection: DomEditSelection | null,
20
+ mutationType: string,
21
+ label: string,
22
+ toast?: boolean,
23
+ ) => {
18
24
  const report = isGsapEditBlockedError(error)
19
25
  ? trackStudioEditBlocked
20
26
  : trackStudioSaveFailure;
@@ -28,7 +34,7 @@ export function useGsapInteractionFailureTelemetry(
28
34
  targetSelector: selection?.selector,
29
35
  targetSourceFile: selection?.sourceFile,
30
36
  });
31
- const message = failureToast(error);
37
+ const message = failureToast(error, toast !== false);
32
38
  if (message) showToast(message, "error");
33
39
  },
34
40
  [activeCompPath, showToast],