@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -14,38 +14,56 @@ const SOURCE =
14
14
  '<!doctype html><html><head></head><body><div data-hf-id="hf-card" style="color: red">Card</div>' +
15
15
  '<div id="clip" class="clip" data-start="1" data-duration="5" data-track-index="0"></div></body></html>';
16
16
 
17
- // The server patches whatever is on disk; the writer refuses a stale base like If-Match does.
18
- // The first write can be held mid-flight.
17
+ // The server patches whatever is on disk, a font rule included; the writer refuses a stale base
18
+ // like If-Match does. The first write, a patch or a whole file, can be held mid-flight.
19
19
  function fakeProject() {
20
20
  let disk = SOURCE;
21
21
  const refused: string[] = [];
22
22
  let writes = 0;
23
+ let wholeFileWrites = 0;
23
24
  let release = () => {};
24
25
  const firstWrite = new Promise<void>((resolve) => (release = resolve));
26
+ const holdFirst = async () => {
27
+ if (++writes === 1) await firstWrite;
28
+ };
25
29
  vi.stubGlobal(
26
30
  "fetch",
27
31
  vi.fn(async (input: string, init?: RequestInit) => {
28
32
  if (input.includes("/file-mutations/patch-element/")) {
29
- const { operations } = JSON.parse(String(init?.body)) as {
33
+ await holdFirst();
34
+ const { operations, fontFaceCss } = JSON.parse(String(init?.body)) as {
30
35
  operations: Array<{ property?: string; value?: string }>;
36
+ fontFaceCss?: string;
31
37
  };
32
38
  for (const op of operations) {
33
39
  if (op.property === "color") disk = disk.replace(/color: \w+/, `color: ${op.value}`);
34
40
  }
41
+ if (fontFaceCss && !disk.includes(fontFaceCss))
42
+ disk = disk.replace(
43
+ "</head>",
44
+ `<style data-hf-studio-fonts="true">${fontFaceCss}</style></head>`,
45
+ );
35
46
  return Response.json({ ok: true, changed: true, matched: true, content: disk });
36
47
  }
37
48
  return Response.json({ content: disk });
38
49
  }),
39
50
  );
40
51
  const writeProjectFile = async (path: string, content: string, expected?: string) => {
41
- if (++writes === 1) await firstWrite;
52
+ wholeFileWrites += 1;
53
+ await holdFirst();
42
54
  if (expected !== undefined && expected !== disk && content !== disk) {
43
55
  refused.push(path);
44
56
  throw new Error(`409 conflict on ${path}`);
45
57
  }
46
58
  disk = content;
47
59
  };
48
- return { writeProjectFile, refused, release: () => release(), read: () => disk };
60
+ return {
61
+ writeProjectFile,
62
+ refused,
63
+ release: () => release(),
64
+ read: () => disk,
65
+ wholeFileWrites: () => wholeFileWrites,
66
+ };
49
67
  }
50
68
 
51
69
  type Project = ReturnType<typeof fakeProject>;
@@ -158,7 +176,7 @@ afterEach(() => {
158
176
  });
159
177
 
160
178
  describe("canvas edit commits share the file queue", () => {
161
- it("lands a second canvas commit started while the first one's font write is held", async () => {
179
+ it("lands a second canvas commit started while the first one's font save is held", async () => {
162
180
  const project = fakeProject();
163
181
  const { hook, selection, toasts } = mountCanvasCommits(project);
164
182
  const font = {
@@ -174,8 +192,55 @@ describe("canvas edit commits share the file queue", () => {
174
192
  );
175
193
 
176
194
  expect(toasts).toEqual([]);
177
- expect(project.read()).toContain("@font-face");
195
+ expect(project.read().match(/@font-face/g)).toHaveLength(1);
178
196
  expect(project.read()).toContain("color: green");
197
+ expect(project.wholeFileWrites(), "the font rides the edit's own patch").toBe(0);
198
+ });
199
+
200
+ it("writes a batch's prepared content over the patch and saves it once", async () => {
201
+ const project = fakeProject();
202
+ const { hook, selection, toasts } = mountCanvasCommits(project);
203
+ project.release();
204
+
205
+ const saved = await hook.handleDomAttributeBatchCommit(
206
+ selection,
207
+ [{ type: "attribute", property: "data-note", value: "1" }],
208
+ {
209
+ label: "Edit with prepared content",
210
+ prepareContent: (html) => html.replace("</head>", '<meta data-prepared="1"></head>'),
211
+ },
212
+ );
213
+
214
+ expect(saved).toBe(true);
215
+ expect(toasts).toEqual([]);
216
+ expect(project.read().match(/data-prepared="1"/g)).toHaveLength(1);
217
+ expect(project.wholeFileWrites()).toBe(1);
218
+ });
219
+
220
+ it("reports a failed follow-up write as a failed commit, not as saved", async () => {
221
+ const project = fakeProject();
222
+ project.release();
223
+ const failing = {
224
+ ...project,
225
+ writeProjectFile: async () => {
226
+ throw new Error("409 conflict");
227
+ },
228
+ };
229
+ const { hook, selection, toasts } = mountCanvasCommits(failing);
230
+
231
+ const saved = await hook.handleDomAttributeBatchCommit(
232
+ selection,
233
+ [{ type: "attribute", property: "data-note", value: "1" }],
234
+ {
235
+ label: "Edit with prepared content",
236
+ prepareContent: (html) => html.replace("</head>", '<meta data-prepared="1"></head>'),
237
+ },
238
+ );
239
+
240
+ expect(saved).toBe(false);
241
+ expect(toasts).toHaveLength(1);
242
+ expect(toasts[0]).toContain("couldn't finish updating");
243
+ expect(project.read()).not.toContain("data-prepared");
179
244
  });
180
245
 
181
246
  it("lands a canvas commit started while a timeline save is writing", async () => {
@@ -1,4 +1,5 @@
1
1
  import type { DomEditSelection } from "../components/editor/domEditing";
2
+ import type { ImportedFontAsset } from "../components/editor/fontAssets";
2
3
  import type { PatchOperation, PatchTarget } from "../utils/sourcePatcher";
3
4
 
4
5
  export interface DomEditPatchBatch {
@@ -53,7 +54,16 @@ export type PersistDomEditOperations = (
53
54
  coalesceKey?: string;
54
55
  coalesceMs?: number;
55
56
  skipRefresh?: boolean;
57
+ deferRender?: boolean;
58
+ importedFont?: ImportedFontAsset;
56
59
  prepareContent?: (html: string, sourceFile: string) => string;
57
60
  shouldSave?: () => boolean;
58
61
  },
59
62
  ) => Promise<DomEditPersistOutcome | undefined>;
63
+
64
+ /** Several data-* and HTML attribute ops on one element as ONE persist and ONE undo entry. */
65
+ export type CommitDomAttributeBatch = (
66
+ selection: DomEditSelection,
67
+ operations: PatchOperation[],
68
+ options: { label: string; prepareContent?: (html: string) => string },
69
+ ) => Promise<boolean>;
@@ -19,6 +19,15 @@ export class DomEditPersistUnsafeValueError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ export class DomEditPersistPreparedWriteError extends Error {
23
+ readonly alreadyToasted = true;
24
+
25
+ constructor(targetPath: string) {
26
+ super(`The edit saved but its follow-up write to ${targetPath} did not`);
27
+ this.name = "DomEditPersistPreparedWriteError";
28
+ }
29
+ }
30
+
22
31
  export type DomEditPersistFailureSelection = Pick<
23
32
  DomEditSelection,
24
33
  "label" | "hfId" | "id" | "selector" | "selectorIndex" | "sourceFile"
@@ -48,7 +57,9 @@ function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
48
57
 
49
58
  export function wasAlreadyToasted(error: unknown): boolean {
50
59
  return (
51
- (error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
60
+ (error instanceof DomEditPersistUnsafeValueError ||
61
+ error instanceof DomEditPersistPreparedWriteError ||
62
+ error instanceof StudioSaveHttpError) &&
52
63
  error.alreadyToasted
53
64
  );
54
65
  }
@@ -2,6 +2,7 @@
2
2
  import { expect, it } from "vitest";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
5
+ import { importedFontFaceCssFor } from "../utils/studioFontHelpers";
5
6
  import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
6
7
 
7
8
  const selection = (id: string): DomEditSelection =>
@@ -28,7 +29,7 @@ const poppins = (file: string, weight: string): ImportedFontAsset => ({
28
29
  });
29
30
 
30
31
  it("each font a host hands over is saved as its own face, with the weight and style it draws", async () => {
31
- let html = '<html><head></head><body><h1 id="title"></h1><p id="subtitle"></p></body></html>';
32
+ const faces: string[] = [];
32
33
  let listed: ImportedFontAsset | null = null;
33
34
  const context: DomStyleCommitContext = {
34
35
  activeCompPath: "index.html",
@@ -39,16 +40,16 @@ it("each font a host hands over is saved as its own face, with the weight and st
39
40
  persistDomEditOperations: (async (
40
41
  _selection: DomEditSelection,
41
42
  _operations: unknown,
42
- options?: { prepareContent?: (html: string, sourceFile: string) => string },
43
+ options?: { importedFont?: ImportedFontAsset },
43
44
  ) => {
44
- html = options?.prepareContent?.(html, "index.html") ?? html;
45
+ if (options?.importedFont)
46
+ faces.push(importedFontFaceCssFor(options.importedFont, "index.html"));
45
47
  }) as unknown as DomStyleCommitContext["persistDomEditOperations"],
46
48
  };
47
49
  listed = poppins("Poppins-SemiBold.ttf", "600");
48
50
  await commitDomStyles(context, selection("title"), { "font-family": "Poppins" });
49
51
  listed = poppins("Poppins-Regular.ttf", "400");
50
52
  await commitDomStyles(context, selection("subtitle"), { "font-family": "Poppins" });
51
- const faces = html.match(/@font-face[^}]*}/g) ?? [];
52
53
  expect(faces).toHaveLength(2);
53
54
  expect(faces[0]).toContain('Poppins-SemiBold.ttf"); font-weight: 600; font-style: normal;');
54
55
  expect(faces[1]).toContain('Poppins-Regular.ttf"); font-weight: 400; font-style: normal;');
@@ -4,11 +4,7 @@ import {
4
4
  isManualGeometryStyleProperty,
5
5
  normalizeDomEditStyleValue,
6
6
  } from "../utils/studioHelpers";
7
- import {
8
- injectPreviewGoogleFont,
9
- injectPreviewImportedFont,
10
- ensureImportedFontFace,
11
- } from "../utils/studioFontHelpers";
7
+ import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
12
8
  import {
13
9
  buildDomEditStylePatchOperation,
14
10
  findElementForSelection,
@@ -105,9 +101,7 @@ export async function commitDomStyles(
105
101
  label: "Edit layer style",
106
102
  // Inline styles are already live, so a reload would only blank the preview.
107
103
  skipRefresh: true,
108
- prepareContent: importedFont
109
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
110
- : undefined,
104
+ importedFont: importedFont ?? undefined,
111
105
  }),
112
106
  shouldRevert: ownsAny,
113
107
  revert: () => {
@@ -273,14 +273,9 @@ export async function commitStaticGsapRotation(
273
273
  }
274
274
 
275
275
  /**
276
- * Commit a STATIC element resize as a `tl.set("#el",{width,height})` — the
277
- * single-source size channel for elements with no size animation (mirrors
278
- * `commitStaticGsapPosition`). Use this instead of a single-stop `keyframes`
279
- * tween: one keyframe at the playhead % renders NaN/0 at every other frame, so
280
- * the element collapses/disappears (worst when resized off the 0% mark). A `set`
281
- * holds the size at all times. Re-resizing an element that already has a size
282
- * `set` UPDATES it in place with one `update-properties`; a new element
283
- * gets one `add` with `method:"set"`.
276
+ * A static resize as `tl.set("#el",{width,height})`, only where the script already writes the size
277
+ * (else it is CSS). A set, not a one-stop keyframe tween, which renders NaN/0 off its keyframe.
278
+ * Updates an existing size set in place, else adds one.
284
279
  */
285
280
  export async function commitStaticGsapSize(
286
281
  selection: DomEditSelection,
@@ -1,4 +1,5 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
2
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
4
  import { usePlayerStore } from "../player/store/playerStore";
4
5
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
@@ -179,7 +180,11 @@ async function commitFlatViaKeyframes(
179
180
  if (runtime && ts !== null) {
180
181
  const { gsapLib, el, mainTl } = runtime;
181
182
  const draggedValues: Record<string, number> = {};
182
- for (const key of Object.keys(properties)) {
183
+ const channelsTheClearWipes = new Set([
184
+ ...Object.keys(properties),
185
+ ...PROPERTY_GROUPS.position,
186
+ ]);
187
+ for (const key of channelsTheClearWipes) {
183
188
  const v = Number(gsapLib.getProperty(el, key));
184
189
  if (Number.isFinite(v)) draggedValues[key] = v;
185
190
  }
@@ -44,7 +44,8 @@ export type GsapEditOutcome =
44
44
  ownsDragOffset?: boolean;
45
45
  }
46
46
  | { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail }
47
- | { status: "element-offset" };
47
+ | { status: "element-offset" }
48
+ | { status: "element-size" };
48
49
 
49
50
  export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
50
51
  "no-selector": "This layer needs a stable selector before Studio can save the edit.",
@@ -0,0 +1,136 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, 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 { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
8
+ import { mountReactHarness } from "./domSelectionTestHarness";
9
+ import { useDomGeometryCommits } from "./useDomGeometryCommits";
10
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ // The bench's hold fixture: an off-timeline `gsap.set` positions #target; its size is stylesheet CSS.
19
+ const positionHold = {
20
+ id: "#target-set-0-position",
21
+ targetSelector: "#target",
22
+ propertyGroup: "position",
23
+ method: "set",
24
+ properties: { x: 40, y: 20 },
25
+ position: 0,
26
+ resolvedStart: 0,
27
+ duration: 0,
28
+ global: true,
29
+ } as unknown as GsapAnimation;
30
+
31
+ const widthTween = {
32
+ id: "#target-to-0-size",
33
+ targetSelector: "#target",
34
+ propertyGroup: "size",
35
+ method: "to",
36
+ properties: { width: 400, height: 300 },
37
+ position: 0,
38
+ resolvedStart: 0,
39
+ duration: 4,
40
+ } as unknown as GsapAnimation;
41
+
42
+ function mount(animations: GsapAnimation[]) {
43
+ const element = Object.assign(document.createElement("div"), {
44
+ id: "target",
45
+ _gsap: { renderTransform: () => {} },
46
+ });
47
+ // GSAP's own inline mask on an element whose transform it renders.
48
+ element.style.setProperty("scale", "none");
49
+ document.body.append(element);
50
+ const gsap = { getProperty: () => 0, set: vi.fn() };
51
+ const iframe = {
52
+ contentWindow: { gsap, __timelines: {} },
53
+ contentDocument: document,
54
+ } as unknown as HTMLIFrameElement;
55
+ const selection = { element, id: "target", selector: "#target" } as unknown as DomEditSelection;
56
+ const commitMutation = vi.fn().mockResolvedValue(undefined);
57
+ const commitPatch = vi.fn().mockResolvedValue(undefined);
58
+ let resize!: ReturnType<typeof useGsapAwareEditing>["handleGsapAwareBoxSizeCommit"];
59
+ function Harness() {
60
+ const { stageElementPositionOffset, handleDomBoxSizeCommit } = useDomGeometryCommits({
61
+ showToast: vi.fn(),
62
+ commitPositionPatchToHtml: commitPatch,
63
+ readOnlyPreview: false,
64
+ });
65
+ resize = useGsapAwareEditing({
66
+ domEditSelection: selection,
67
+ selectedGsapAnimations: animations,
68
+ gsapCommitMutation: commitMutation,
69
+ previewIframeRef: { current: iframe },
70
+ showToast: vi.fn(),
71
+ bumpGsapCache: vi.fn(),
72
+ makeFetchFallback: () => async () => animations,
73
+ trackGsapInteractionFailure: vi.fn(),
74
+ stageElementPositionOffset,
75
+ handleDomBoxSizeCommit,
76
+ handleDomRotationCommit: vi.fn(),
77
+ commitPositionPatchToHtml: commitPatch,
78
+ addGsapAnimation: vi.fn(),
79
+ convertToKeyframes: vi.fn(),
80
+ setArcPath: vi.fn(),
81
+ updateArcSegment: vi.fn(),
82
+ }).handleGsapAwareBoxSizeCommit;
83
+ return null;
84
+ }
85
+ const root = mountReactHarness(<Harness />);
86
+ const size = { width: 340, height: 227 };
87
+ applyStudioBoxSizeDraft(element, size);
88
+ return { selection, size, commitMutation, commitPatch, resize, root };
89
+ }
90
+
91
+ const writesSize = (mutation: { properties?: object; keyframes?: Array<{ properties: object }> }) =>
92
+ [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)].some(
93
+ (props) => !!props && ("width" in props || "height" in props),
94
+ );
95
+
96
+ describe("resizing an element GSAP positions", () => {
97
+ it("saves a size the script never writes as the element's CSS width/height, not a tl.set", async () => {
98
+ const h = mount([positionHold]);
99
+
100
+ await act(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
101
+
102
+ const mutations = h.commitMutation.mock.calls.map((call) => call[1]);
103
+ expect(mutations.filter(writesSize)).toEqual([]);
104
+ // The anchor still moves the GSAP hold, in the same undo step as the CSS size.
105
+ expect(mutations.some((m) => m.animationId === positionHold.id)).toBe(true);
106
+ const [, patches, options] = h.commitPatch.mock.calls.at(-1)!;
107
+ expect(patches).toEqual(
108
+ expect.arrayContaining([
109
+ { type: "inline-style", property: "width", value: "340px" },
110
+ { type: "inline-style", property: "height", value: "227px" },
111
+ ]),
112
+ );
113
+ expect(patches.filter((p: { property: string }) => p.property === "scale")).toEqual([]);
114
+ expect(options.coalesceKey).toBe(h.commitMutation.mock.calls[0]![2].coalesceKey);
115
+ // Saved before the GSAP write and without its own render, so the GSAP reload renders last.
116
+ expect(options.deferRender).toBe(true);
117
+ expect(h.commitPatch.mock.invocationCallOrder.at(-1)).toBeLessThan(
118
+ h.commitMutation.mock.invocationCallOrder[0]!,
119
+ );
120
+ act(() => h.root.unmount());
121
+ });
122
+
123
+ it("still saves the size through GSAP when a tween animates it", async () => {
124
+ const h = mount([positionHold, widthTween]);
125
+
126
+ await act(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
127
+
128
+ const mutations = h.commitMutation.mock.calls.map((call) => call[1]);
129
+ expect(mutations.filter(writesSize).length).toBeGreaterThan(0);
130
+ const cssSize = h.commitPatch.mock.calls
131
+ .flatMap((call) => call[1])
132
+ .filter((p: { property: string }) => p.property === "width" || p.property === "height");
133
+ expect(cssSize).toEqual([]);
134
+ act(() => h.root.unmount());
135
+ });
136
+ });
@@ -238,7 +238,9 @@ async function runCase(testCase: Case): Promise<string[]> {
238
238
  async () => animations,
239
239
  );
240
240
 
241
- const { scale, size } = committed(commitMutation.mock.calls);
241
+ const { scale, size: written } = committed(commitMutation.mock.calls);
242
+ const size =
243
+ outcome.status === "element-size" ? { w: testCase.drop.w, h: testCase.drop.h } : written;
242
244
  const settled = renderRect(
243
245
  { box: size ?? testCase.box, pos: { ...live.pos }, scale: scale ?? testCase.liveScale },
244
246
  testCase.rotation,
@@ -529,7 +529,7 @@ function titleSelection(): DomEditSelection {
529
529
  return { id: "title", selector: "#title", element: el } as DomEditSelection;
530
530
  }
531
531
 
532
- it("writes a plain size on an element whose only tween is a fade", async () => {
532
+ it("hands the size to the element's CSS when its only tween is a fade", async () => {
533
533
  const fade = {
534
534
  id: "#title-to-0-visual",
535
535
  targetSelector: "#title",
@@ -550,14 +550,6 @@ it("writes a plain size on an element whose only tween is a fade", async () => {
550
550
  commitMutation,
551
551
  );
552
552
 
553
- expect(handled).toMatchObject({ status: "persisted" });
554
- const mutations = commitMutation.mock.calls.map((call) => call[1] as { type: string });
555
- expect(mutations.filter((m) => /keyframe/i.test(m.type))).toEqual([]);
556
- expect(mutations).toContainEqual({
557
- type: "add",
558
- targetSelector: "#title",
559
- method: "set",
560
- position: 0,
561
- properties: { width: 424, height: 237 },
562
- });
553
+ expect(handled).toEqual({ status: "element-size" });
554
+ expect(commitMutation).not.toHaveBeenCalled();
563
555
  });
@@ -38,6 +38,7 @@ import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutc
38
38
  import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
39
39
 
40
40
  const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
41
+ const SIZE_PROPS = new Set(["width", "height"]);
41
42
 
42
43
  /** Build identity (zero / one) values for each property in `source`. */
43
44
  function synthesizeIdentityProps(
@@ -101,7 +102,7 @@ export async function tryGsapResizeIntercept(
101
102
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
102
103
  const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
103
104
  if (outcome.status === "blocked") return outcome;
104
- const { resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
105
+ const { allKnownAnimations, resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
105
106
  animations,
106
107
  fetchedAnimations,
107
108
  );
@@ -131,6 +132,10 @@ export async function tryGsapResizeIntercept(
131
132
  size,
132
133
  });
133
134
  if (!anim || isInstantHold(anim)) {
135
+ const scriptWritesSize = allKnownAnimations.some((a) =>
136
+ animationWritesAnyProperty(a, SIZE_PROPS),
137
+ );
138
+ if (!scriptWritesSize) return { status: "element-size" };
134
139
  const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
135
140
  if (!sel) return { status: "blocked", reason: "no-selector" };
136
141
  // A scale hold is not a size hold.
@@ -541,7 +546,6 @@ export async function tryGsapResizeIntercept(
541
546
  return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
542
547
  }
543
548
 
544
- const SIZE_PROPS = new Set(["width", "height"]);
545
549
  const backfillDefaults: Record<string, number> = {};
546
550
  for (const k of Object.keys(runtimeProps)) {
547
551
  if (SIZE_PROPS.has(k)) continue;
@@ -163,6 +163,5 @@ it("leaves the drag offset to the caller when it commits size, not scale", async
163
163
  async () => [scaleHold],
164
164
  );
165
165
 
166
- expect(outcome.status).toBe("persisted");
167
- expect(outcome.status === "persisted" && outcome.ownsDragOffset).not.toBe(true);
166
+ expect(outcome.status).toBe("element-size");
168
167
  });
@@ -567,3 +567,58 @@ describe("tryGsapDragIntercept — motion paths", () => {
567
567
  );
568
568
  });
569
569
  });
570
+
571
+ describe("tryGsapDragIntercept — position self-heal", () => {
572
+ const xyTween = {
573
+ ...stalePositionAnim,
574
+ id: "#puck-b-to-0-position",
575
+ position: 0,
576
+ resolvedStart: 0,
577
+ };
578
+ const centringSet = {
579
+ id: "#puck-b-set-0-position",
580
+ targetSelector: "#puck-b",
581
+ propertyGroup: "position",
582
+ method: "set",
583
+ properties: { xPercent: -50, yPercent: -50 },
584
+ position: 0,
585
+ duration: 0,
586
+ } as unknown as GsapAnimation;
587
+ const xySet = { ...centringSet, properties: { x: 10, y: 20 } } as GsapAnimation;
588
+ const liveTween = {
589
+ targets: () => [{ id: "puck-b" }],
590
+ vars: { x: -180, y: -60, duration: 2 },
591
+ duration: () => 2,
592
+ startTime: () => 0,
593
+ };
594
+
595
+ async function drag(second: GsapAnimation) {
596
+ usePlayerStore.setState({ currentTime: 1 });
597
+ const animations = [second, xyTween];
598
+ const commitMutation = vi.fn();
599
+ await tryGsapDragIntercept(
600
+ selection,
601
+ { x: -50, y: 30 },
602
+ animations,
603
+ fakeIframe("puck-b", [liveTween]),
604
+ commitMutation,
605
+ async () => animations,
606
+ );
607
+ return commitMutation.mock.calls.map(([, mutation]) => mutation);
608
+ }
609
+
610
+ it("keeps an xPercent/yPercent centring set and moves the x/y tween", async () => {
611
+ const mutations = await drag(centringSet);
612
+ expect(mutations.map((m) => m.type)).not.toContain("consolidate-position-writes");
613
+ expect(mutations.length).toBeGreaterThan(0);
614
+ expect(mutations.every((m) => m.animationId === xyTween.id)).toBe(true);
615
+ });
616
+
617
+ it("consolidates a second x/y write", async () => {
618
+ const mutations = await drag(xySet);
619
+ expect(mutations[0]).toMatchObject({
620
+ type: "consolidate-position-writes",
621
+ keepAnimationId: xyTween.id,
622
+ });
623
+ });
624
+ });
@@ -9,6 +9,7 @@
9
9
  * easing, or seek position.
10
10
  */
11
11
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
12
+ import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
12
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
14
  import { usePlayerStore } from "../player/store/playerStore";
14
15
 
@@ -245,8 +246,7 @@ export async function tryGsapDragIntercept(
245
246
  // the live keyframed/real tween if present (else any), strip the rest, so the
246
247
  // commit below updates ONE write instead of fighting duplicates.
247
248
  let workingAnimations = animations;
248
- const isPosWrite = (a: GsapAnimation) =>
249
- a.targetSelector === selector && a.propertyGroup === "position";
249
+ const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && isXYPositionWrite(a);
250
250
  if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
251
251
  const fresh = await fetchFallbackAnimations();
252
252
  const dupes = fresh.filter(isPosWrite);
@@ -331,7 +331,9 @@ export async function tryGsapDragIntercept(
331
331
  const fresh = await fetchFallbackAnimations();
332
332
  const freshMatch = fresh.find(
333
333
  (a) =>
334
- a.targetSelector === posAnim!.targetSelector && a.propertyGroup === posAnim!.propertyGroup,
334
+ a.targetSelector === posAnim!.targetSelector &&
335
+ a.propertyGroup === posAnim!.propertyGroup &&
336
+ isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
335
337
  );
336
338
  if (freshMatch && freshMatch.id !== posAnim.id) {
337
339
  posAnim = freshMatch;
@@ -12,6 +12,7 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-
12
12
  import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
13
13
  import { roundTo3 } from "../utils/rounding";
14
14
  import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
15
+ import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
15
16
 
16
17
  /**
17
18
  * A GSAP tween's `vars` object — intentionally open: it mixes channel values
@@ -446,9 +447,6 @@ export const ROTATION_CHANNELS: string[] = [
446
447
  ...["rotate", "rotateX", "rotateY", "rotateZ"],
447
448
  ];
448
449
 
449
- const gsapRendersTransform = (el: Element) =>
450
- !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
451
-
452
450
  /** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
453
451
  * transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
454
452
  export function gsapWritesPosition(el: Element): boolean {