@hyperframes/studio 0.8.106 → 0.8.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
  3. package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
  5. package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
  6. package/dist/chunk-4OZC426H.js.map +1 -0
  7. package/dist/index.d.ts +367 -11
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1064 -714
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +5 -3
  14. package/src/agentToolsExport.test.ts +8 -0
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/components/StudioRightPanels.tsx +2 -0
  17. package/src/components/TimelineToolbar.test.tsx +27 -0
  18. package/src/components/TimelineToolbar.tsx +2 -5
  19. package/src/components/editor/DomEditOverlay.tsx +12 -18
  20. package/src/components/editor/PropertyPanel.tsx +1 -14
  21. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  22. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  23. package/src/components/editor/SnapToolbar.tsx +3 -4
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  25. package/src/components/editor/fontAssets.test.ts +12 -22
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  27. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  28. package/src/components/editor/manualEdits.test.ts +4 -3
  29. package/src/components/editor/manualEditsDom.ts +28 -5
  30. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  31. package/src/components/editor/manualOffsetDrag.ts +4 -1
  32. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  33. package/src/components/editor/mediaAudioEdits.ts +253 -0
  34. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  36. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  37. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  40. package/src/components/editor/useDomEditNudge.ts +41 -23
  41. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  42. package/src/components/panels/SlideshowPanel.tsx +12 -7
  43. package/src/contexts/DomEditContext.test.tsx +62 -0
  44. package/src/contexts/DomEditContext.tsx +94 -77
  45. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  46. package/src/hooks/domEditCommitTypes.ts +10 -0
  47. package/src/hooks/domEditPersistFailure.ts +12 -1
  48. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  49. package/src/hooks/domStyleCommit.ts +2 -8
  50. package/src/hooks/gsapDragCommit.ts +3 -8
  51. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  52. package/src/hooks/gsapEditOutcome.ts +2 -1
  53. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  55. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  56. package/src/hooks/gsapResizeIntercept.ts +6 -2
  57. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  58. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  59. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  60. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  61. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  62. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  63. package/src/hooks/useAppHotkeys.test.ts +17 -0
  64. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  65. package/src/hooks/useAppHotkeys.ts +3 -3
  66. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  67. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  68. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  69. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  70. package/src/hooks/useDomEditCommits.ts +2 -0
  71. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  72. package/src/hooks/useDomEditPersist.ts +28 -13
  73. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  74. package/src/hooks/useDomEditSession.ts +2 -3
  75. package/src/hooks/useDomEditTextCommits.ts +4 -8
  76. package/src/hooks/useDomGeometryCommits.ts +11 -2
  77. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  78. package/src/hooks/useGsapAwareEditing.ts +6 -6
  79. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  80. package/src/hooks/useGsapScriptCommits.ts +5 -4
  81. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  82. package/src/hooks/useInlineTextEdit.ts +9 -7
  83. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  84. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  85. package/src/hooks/usePreviewPersistence.ts +6 -0
  86. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  87. package/src/index.ts +7 -0
  88. package/src/player/components/Timeline.test.ts +0 -21
  89. package/src/player/components/Timeline.tsx +0 -1
  90. package/src/player/components/timelineLayout.ts +0 -28
  91. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  92. package/src/player/lib/playbackShortcuts.ts +3 -7
  93. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  94. package/src/player/lib/timelineElementHelpers.ts +16 -2
  95. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  96. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  97. package/src/utils/gsapSoftReload.ts +3 -2
  98. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  99. package/src/utils/gsapUndoRestore.test.ts +145 -1
  100. package/src/utils/gsapUndoRestore.ts +70 -21
  101. package/src/utils/sourcePatcher.ts +1 -1
  102. package/src/utils/studioFontHelpers.ts +2 -23
  103. package/src/utils/studioHelpers.test.ts +30 -0
  104. package/src/utils/studioHelpers.ts +18 -4
  105. package/src/utils/studioPendingEdits.test.ts +63 -0
  106. package/src/utils/studioPendingEdits.ts +16 -7
  107. package/src/utils/typingTarget.test.ts +13 -0
  108. package/src/utils/typingTarget.ts +5 -2
  109. package/src/webmcp/useStudioAgentTools.ts +3 -1
  110. package/dist/chunk-ICTZJPQQ.js.map +0 -1
@@ -2,85 +2,88 @@
2
2
  import type { useDomEditSession } from "../hooks/useDomEditSession";
3
3
  import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
4
4
  import { createStableContext } from "../utils/hmrStableContext";
5
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
5
6
 
6
7
  type DomEditValue = ReturnType<typeof useDomEditSession>;
7
8
 
8
- export interface DomEditActionsValue extends Pick<
9
- DomEditValue,
10
- | "handleTimelineElementSelect"
11
- | "handlePreviewCanvasMouseDown"
12
- | "handlePreviewCanvasPointerMove"
13
- | "handlePreviewCanvasPointerLeave"
14
- | "applyDomSelection"
15
- | "clearDomSelection"
16
- | "refreshDomEditSelectionFromPreview"
17
- | "handleDomStyleCommit"
18
- | "handleDomStyleCommitForSelection"
19
- | "handleDomAttributeCommit"
20
- | "handleDomAttributeLiveCommit"
21
- | "handleDomAttributeQuietCommit"
22
- | "handleDomHtmlAttributeCommit"
23
- | "handleDomAttributesCommit"
24
- | "handleDomPathOffsetCommit"
25
- | "handleDomGroupPathOffsetCommit"
26
- | "handleDomZIndexReorderCommit"
27
- | "handleDomBoxSizeCommit"
28
- | "handleDomRotationCommit"
29
- | "handleDomManualEditsReset"
30
- | "handleDomTextCommit"
31
- | "handleDomTextCommitForSelection"
32
- | "handleDomRichTextCommit"
33
- | "handleDomTextFieldStyleCommit"
34
- | "handleDomAddTextField"
35
- | "handleDomRemoveTextField"
36
- | "getGsapAnimationsForSelection"
37
- | "handleAskAgent"
38
- | "handleAgentModalSubmit"
39
- | "handleBlockedDomMove"
40
- | "handleDomManualDragStart"
41
- | "handleDomEditElementDelete"
42
- | "handleGroupSelection"
43
- | "handleUngroupSelection"
44
- | "setActiveGroupElement"
45
- | "buildDomSelectionFromTarget"
46
- | "buildDomSelectionForTimelineElement"
47
- | "updateDomEditHoverSelection"
48
- | "resolveImportedFontAsset"
49
- | "setAgentModalOpen"
50
- | "setAgentPromptSelectionContext"
51
- | "setAgentModalAnchorPoint"
52
- | "handleGsapUpdateProperty"
53
- | "handleGsapUpdateMeta"
54
- | "handleGsapDeleteAnimation"
55
- | "handleGsapDeleteAllForElement"
56
- | "handleGsapAddAnimation"
57
- | "handleGsapAddProperty"
58
- | "handleGsapRemoveProperty"
59
- | "handleGsapUpdateFromProperty"
60
- | "handleGsapAddFromProperty"
61
- | "handleGsapRemoveFromProperty"
62
- | "handleGsapAddKeyframe"
63
- | "handleGsapAddKeyframeBatch"
64
- | "handleGsapRemoveKeyframe"
65
- | "handleGsapMoveKeyframeToPlayhead"
66
- | "handleGsapMoveKeyframe"
67
- | "handleGsapResizeKeyframedTween"
68
- | "handleGsapConvertToKeyframes"
69
- | "handleGsapRemoveAllKeyframes"
70
- | "handleResetSelectedElementKeyframes"
71
- | "commitAnimatedProperty"
72
- | "commitAnimatedProperties"
73
- | "handleSetArcPath"
74
- | "handleUpdateArcSegment"
75
- | "handleUnroll"
76
- | "invalidateGsapCache"
77
- | "previewIframeRef"
78
- | "commitMutation"
79
- | "applyMarqueeSelection"
80
- | "handleUpdateKeyframeEase"
81
- | "handleUpdateSegmentEase"
82
- | "handleSetAllKeyframeEases"
83
- > {}
9
+ const SAVES_AN_EDIT = {
10
+ handleTimelineElementSelect: false,
11
+ handlePreviewCanvasMouseDown: false,
12
+ handlePreviewCanvasPointerMove: false,
13
+ handlePreviewCanvasPointerLeave: false,
14
+ applyDomSelection: false,
15
+ clearDomSelection: false,
16
+ refreshDomEditSelectionFromPreview: false,
17
+ handleDomStyleCommit: true,
18
+ handleDomStyleCommitForSelection: true,
19
+ handleDomAttributeCommit: true,
20
+ handleDomAttributeLiveCommit: false,
21
+ handleDomAttributeQuietCommit: true,
22
+ handleDomHtmlAttributeCommit: true,
23
+ handleDomAttributesCommit: true,
24
+ handleDomAttributeBatchCommit: true,
25
+ handleDomPathOffsetCommit: true,
26
+ handleDomGroupPathOffsetCommit: true,
27
+ handleDomZIndexReorderCommit: true,
28
+ handleDomBoxSizeCommit: true,
29
+ handleDomRotationCommit: true,
30
+ handleDomManualEditsReset: true,
31
+ handleDomTextCommit: true,
32
+ handleDomTextCommitForSelection: true,
33
+ handleDomRichTextCommit: true,
34
+ handleDomTextFieldStyleCommit: true,
35
+ handleDomAddTextField: true,
36
+ handleDomRemoveTextField: true,
37
+ getGsapAnimationsForSelection: false,
38
+ handleAskAgent: false,
39
+ handleAgentModalSubmit: false,
40
+ handleBlockedDomMove: false,
41
+ handleDomManualDragStart: false,
42
+ handleDomEditElementDelete: true,
43
+ handleGroupSelection: true,
44
+ handleUngroupSelection: true,
45
+ setActiveGroupElement: false,
46
+ buildDomSelectionFromTarget: false,
47
+ buildDomSelectionForTimelineElement: false,
48
+ updateDomEditHoverSelection: false,
49
+ resolveImportedFontAsset: false,
50
+ setAgentModalOpen: false,
51
+ setAgentPromptSelectionContext: false,
52
+ setAgentModalAnchorPoint: false,
53
+ handleGsapUpdateProperty: true,
54
+ handleGsapUpdateMeta: true,
55
+ handleGsapDeleteAnimation: true,
56
+ handleGsapDeleteAllForElement: true,
57
+ handleGsapAddAnimation: true,
58
+ handleGsapAddProperty: true,
59
+ handleGsapRemoveProperty: true,
60
+ handleGsapUpdateFromProperty: true,
61
+ handleGsapAddFromProperty: true,
62
+ handleGsapRemoveFromProperty: true,
63
+ handleGsapAddKeyframe: true,
64
+ handleGsapAddKeyframeBatch: true,
65
+ handleGsapRemoveKeyframe: true,
66
+ handleGsapMoveKeyframeToPlayhead: true,
67
+ handleGsapMoveKeyframe: true,
68
+ handleGsapResizeKeyframedTween: true,
69
+ handleGsapConvertToKeyframes: true,
70
+ handleGsapRemoveAllKeyframes: true,
71
+ handleResetSelectedElementKeyframes: true,
72
+ commitAnimatedProperty: true,
73
+ commitAnimatedProperties: true,
74
+ handleSetArcPath: true,
75
+ handleUpdateArcSegment: true,
76
+ handleUnroll: true,
77
+ invalidateGsapCache: false,
78
+ previewIframeRef: false,
79
+ commitMutation: true,
80
+ applyMarqueeSelection: false,
81
+ handleUpdateKeyframeEase: true,
82
+ handleUpdateSegmentEase: true,
83
+ handleSetAllKeyframeEases: true,
84
+ } as const satisfies Partial<Record<keyof DomEditValue, boolean>>;
85
+
86
+ export interface DomEditActionsValue extends Pick<DomEditValue, keyof typeof SAVES_AN_EDIT> {}
84
87
 
85
88
  export interface DomEditSelectionValue extends Pick<
86
89
  DomEditValue,
@@ -98,6 +101,15 @@ export interface DomEditSelectionValue extends Pick<
98
101
  | "agentPromptSelectionContext"
99
102
  > {}
100
103
 
104
+ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
105
+ const tracked: Record<string, unknown> = { ...actions };
106
+ for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
107
+ if (!SAVES_AN_EDIT[key]) continue;
108
+ tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
109
+ }
110
+ return tracked as unknown as DomEditActionsValue;
111
+ }
112
+
101
113
  const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
102
114
  "DomEditActionsContext",
103
115
  null,
@@ -164,6 +176,7 @@ export function DomEditProvider({
164
176
  handleDomAttributeQuietCommit,
165
177
  handleDomHtmlAttributeCommit,
166
178
  handleDomAttributesCommit,
179
+ handleDomAttributeBatchCommit,
167
180
  handleDomPathOffsetCommit,
168
181
  handleDomGroupPathOffsetCommit,
169
182
  handleDomZIndexReorderCommit,
@@ -242,7 +255,7 @@ export function DomEditProvider({
242
255
  [],
243
256
  );
244
257
 
245
- const actions = useMemo<DomEditActionsValue>(
258
+ const untrackedActions = useMemo<DomEditActionsValue>(
246
259
  () => ({
247
260
  handleTimelineElementSelect,
248
261
  handlePreviewCanvasMouseDown,
@@ -258,6 +271,7 @@ export function DomEditProvider({
258
271
  handleDomAttributeQuietCommit,
259
272
  handleDomHtmlAttributeCommit,
260
273
  handleDomAttributesCommit,
274
+ handleDomAttributeBatchCommit,
261
275
  handleDomPathOffsetCommit,
262
276
  handleDomGroupPathOffsetCommit,
263
277
  handleDomZIndexReorderCommit,
@@ -333,6 +347,7 @@ export function DomEditProvider({
333
347
  handleDomAttributeQuietCommit,
334
348
  handleDomHtmlAttributeCommit,
335
349
  handleDomAttributesCommit,
350
+ handleDomAttributeBatchCommit,
336
351
  handleDomPathOffsetCommit,
337
352
  handleDomGroupPathOffsetCommit,
338
353
  handleDomZIndexReorderCommit,
@@ -395,6 +410,8 @@ export function DomEditProvider({
395
410
  ],
396
411
  );
397
412
 
413
+ const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]);
414
+
398
415
  const selection = useMemo<DomEditSelectionValue>(
399
416
  () => ({
400
417
  domEditSelection,
@@ -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,