@hyperframes/studio 0.8.116 → 0.8.118

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 (58) hide show
  1. package/dist/assets/{hyperframes-player-mGYvPVpw.js → hyperframes-player-CnpIuNRl.js} +1 -1
  2. package/dist/assets/{index-DYNaXPVf.js → index-B4SbAADe.js} +196 -194
  3. package/dist/assets/{index-Dr3hNpvS.js → index-BZ23-h1_.js} +1 -1
  4. package/dist/assets/{index-BWHt0-ir.js → index-FeRU6G3G.js} +1 -1
  5. package/dist/index.d.ts +29 -19
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2366 -2305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/StudioRightPanels.tsx +3 -6
  11. package/src/components/TimelineToolbar.test.tsx +14 -0
  12. package/src/components/TimelineToolbar.tsx +4 -1
  13. package/src/components/editor/DomEditOverlay.tsx +1 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +149 -25
  15. package/src/components/editor/PropertyPanel.tsx +1 -12
  16. package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
  17. package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
  18. package/src/components/editor/PropertyPanelFlat.tsx +9 -22
  19. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
  20. package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
  21. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  23. package/src/components/editor/manualOffsetDrag.ts +3 -3
  24. package/src/components/editor/plainTranslate.ts +5 -3
  25. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  26. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  27. package/src/components/editor/rotationDraft.ts +2 -1
  28. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  29. package/src/components/editor/usePanelHiddenToggle.ts +44 -0
  30. package/src/contexts/DomEditContext.tsx +3 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  32. package/src/hooks/gsapScriptCommitTypes.ts +1 -1
  33. package/src/hooks/plainRotation.ts +5 -1
  34. package/src/hooks/timelineTrackVisibility.ts +7 -17
  35. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  36. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  37. package/src/hooks/useDomGeometryCommit.ts +6 -9
  38. package/src/hooks/useFreezeFrame.test.ts +39 -3
  39. package/src/hooks/useFreezeFrame.ts +4 -1
  40. package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
  41. package/src/hooks/useGsapAwareEditing.ts +12 -8
  42. package/src/hooks/useGsapAwareGroupMove.ts +2 -2
  43. package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
  44. package/src/hooks/useGsapScriptCommits.ts +9 -7
  45. package/src/hooks/usePersistentEditHistory.test.ts +25 -2
  46. package/src/hooks/usePersistentEditHistory.ts +3 -9
  47. package/src/player/components/Timeline.test.ts +48 -0
  48. package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
  49. package/src/player/components/TimelineTrackHeader.tsx +4 -2
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
  52. package/src/player/components/hiddenToggle.tsx +20 -0
  53. package/src/utils/gsapSoftReload.test.ts +51 -2
  54. package/src/utils/gsapSoftReload.ts +11 -21
  55. package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
  56. package/src/utils/softReloadTargets.ts +4 -3
  57. package/src/utils/studioFileHistory.ts +1 -0
  58. package/src/utils/timelineInspector.ts +10 -5
@@ -0,0 +1,44 @@
1
+ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
2
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
3
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
4
+ import { hiddenToggleVerb } from "../../player/components/hiddenToggle";
5
+ import type { DomEditSelection } from "./domEditingTypes";
6
+
7
+ /** The panel header's hide toggle, or undefined when this selection has no writer for it. */
8
+ export function usePanelHiddenToggle({
9
+ element,
10
+ hidden,
11
+ selectedElementId,
12
+ onToggleElementHidden,
13
+ }: {
14
+ element: Pick<DomEditSelection, "id" | "tagName">;
15
+ hidden: boolean;
16
+ selectedElementId: string | null | undefined;
17
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
18
+ }): (() => void) | undefined {
19
+ const { onSetAudioGroupAttributeQuiet } = useTimelineEditContextOptional();
20
+ const domEditActions = useDomEditActionsContextOptional();
21
+ // A bus has no timeline row, so it mutes through the group's own writer.
22
+ const audioGroupId = element.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ? element.id : null;
23
+ const write = audioGroupId
24
+ ? onSetAudioGroupAttributeQuiet &&
25
+ (() =>
26
+ onSetAudioGroupAttributeQuiet(
27
+ audioGroupId,
28
+ "data-hidden",
29
+ hidden ? null : "",
30
+ `${hiddenToggleVerb(true, hidden)} element`,
31
+ ))
32
+ : selectedElementId && onToggleElementHidden
33
+ ? () => onToggleElementHidden(selectedElementId, !hidden)
34
+ : undefined;
35
+ // The label reads the selection snapshot, so re-read whatever is selected once the write lands.
36
+ return (
37
+ write &&
38
+ (() =>
39
+ void Promise.resolve(write()).then(() => {
40
+ const selection = domEditActions?.domEditSelectionRef.current;
41
+ if (selection) void domEditActions?.refreshDomEditSelectionFromPreview(selection);
42
+ }))
43
+ );
44
+ }
@@ -77,6 +77,7 @@ const SAVES_AN_EDIT = {
77
77
  handleUnroll: true,
78
78
  invalidateGsapCache: false,
79
79
  previewIframeRef: false,
80
+ domEditSelectionRef: false,
80
81
  commitMutation: true,
81
82
  applyMarqueeSelection: false,
82
83
  handleUpdateKeyframeEase: true,
@@ -329,6 +330,7 @@ export function DomEditProvider({
329
330
  handleUnroll,
330
331
  invalidateGsapCache,
331
332
  previewIframeRef,
333
+ domEditSelectionRef,
332
334
  commitMutation: stableCommitMutation,
333
335
  applyMarqueeSelection,
334
336
  handleUpdateKeyframeEase,
@@ -406,6 +408,7 @@ export function DomEditProvider({
406
408
  handleUnroll,
407
409
  invalidateGsapCache,
408
410
  previewIframeRef,
411
+ domEditSelectionRef,
409
412
  stableCommitMutation,
410
413
  applyMarqueeSelection,
411
414
  handleUpdateKeyframeEase,
@@ -449,12 +449,12 @@ export const ROTATION_CHANNELS: string[] = [
449
449
 
450
450
  /** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
451
451
  * transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
452
- export function gsapWritesPosition(el: Element): boolean {
452
+ function gsapWritesPosition(el: Element): boolean {
453
453
  return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
454
454
  }
455
455
 
456
456
  /** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
457
- export function gsapWritesRotation(el: Element): boolean {
457
+ function gsapWritesRotation(el: Element): boolean {
458
458
  return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
459
459
  }
460
460
 
@@ -474,10 +474,23 @@ const BOX_CHANNELS = [
474
474
  ];
475
475
 
476
476
  /** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
477
- export function gsapWritesBox(el: Element): boolean {
477
+ function gsapWritesBox(el: Element): boolean {
478
478
  return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
479
479
  }
480
480
 
481
+ export type EditGesture = "move" | "resize" | "rotate";
482
+ const GSAP_OWNS: Record<EditGesture, (el: Element) => boolean> = {
483
+ move: gsapWritesPosition,
484
+ resize: gsapWritesBox,
485
+ rotate: gsapWritesRotation,
486
+ };
487
+
488
+ /** The one GSAP-or-CSS route decision. Make it when an edit starts and carry it: mid-gesture
489
+ * writes make GSAP render the element, so asking again at commit can flip the route. */
490
+ export function editsPlainCss(el: Element, gesture: EditGesture): boolean {
491
+ return !GSAP_OWNS[gesture](el);
492
+ }
493
+
481
494
  /** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
482
495
  export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
483
496
  const win = el.ownerDocument.defaultView as {
@@ -44,7 +44,7 @@ export interface CommitMutationOptions {
44
44
  * error handling — a failed write still throws.
45
45
  */
46
46
  deferPreviewSync?: boolean;
47
- /** Shares an in-place patch miss with the final render of one multi-write action. */
47
+ /** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
48
48
  previewFallbackLatch?: { pending: boolean };
49
49
  beforeReload?: () => void;
50
50
  /**
@@ -27,7 +27,11 @@ export function savePlainRotation(
27
27
  ? buildClearRotationPatches(element)
28
28
  : [];
29
29
  if (patches.length) clearStudioRotation(element);
30
- const drawn = applyCssRotation(element, next.angle, patches.length ? undefined : next.plain);
30
+ const drawn = applyCssRotation(
31
+ element,
32
+ next.angle,
33
+ patches.length ? undefined : (next.plain ?? undefined),
34
+ );
31
35
  patches.push(...drawn);
32
36
  const turn = drawn.at(-1)!;
33
37
  return commitPositionPatchToHtml(selection, patches, {
@@ -8,7 +8,8 @@ import {
8
8
  trackDisplaySuffix,
9
9
  } from "../player/components/timelineTrackDisplay";
10
10
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
11
- import { isAudioTimelineElement } from "../utils/timelineInspector";
11
+ import { isAudioOnlyTrack } from "../utils/timelineInspector";
12
+ import { hiddenToggleVerb } from "../player/components/hiddenToggle";
12
13
  import type { PatchOperation } from "../utils/sourcePatcher";
13
14
  import {
14
15
  findTimelineElementInIframe,
@@ -206,14 +207,8 @@ export async function toggleTimelineTrackHidden({
206
207
  displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
207
208
  );
208
209
  const trackElements = timelineElements.filter((element) => element.track === track);
209
- const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
210
- const label = isAudioOnlyTrack
211
- ? hidden
212
- ? `Mute track${suffix}`
213
- : `Unmute track${suffix}`
214
- : hidden
215
- ? `Hide track${suffix}`
216
- : `Show track${suffix}`;
210
+ const hiddenBefore = !hidden;
211
+ const label = `${hiddenToggleVerb(isAudioOnlyTrack(trackElements), hiddenBefore)} track${suffix}`;
217
212
  return setElementsHidden({
218
213
  projectId,
219
214
  activeCompPath,
@@ -240,19 +235,14 @@ export async function toggleTimelineElementHidden({
240
235
  }: ToggleTimelineElementHiddenInput): Promise<string[]> {
241
236
  const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
242
237
  const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
238
+ const hiddenBefore = !hidden;
239
+ const verb = hiddenToggleVerb(isAudioOnlyTrack(elements), hiddenBefore);
243
240
  return setElementsHidden({
244
241
  projectId,
245
242
  activeCompPath,
246
243
  elements,
247
244
  hidden,
248
- label:
249
- elements.length > 1
250
- ? hidden
251
- ? `Hide ${elements.length} elements`
252
- : `Show ${elements.length} elements`
253
- : hidden
254
- ? "Hide element"
255
- : "Show element",
245
+ label: elements.length > 1 ? `${verb} ${elements.length} elements` : `${verb} element`,
256
246
  previewIframe,
257
247
  writeProjectFile,
258
248
  recordEdit,
@@ -0,0 +1,335 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditing";
6
+ import type { PatchOperation } from "../utils/sourcePatcher";
7
+ import type { CutoverResult } from "../utils/sdkCutover";
8
+ import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
9
+ import {
10
+ DomEditPersistPreparedWriteError,
11
+ DomEditPersistUnresolvableError,
12
+ DomEditPersistUnsafeValueError,
13
+ } from "./domEditPersistFailure";
14
+ import type { PersistDomEditOperations } from "./domEditCommitTypes";
15
+ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
16
+ import { useDomEditPersist, type UseDomEditPersistParams } from "./useDomEditPersist";
17
+
18
+ const trackStudioEvent = vi.fn();
19
+ const reseekPreviewRuntime = vi.fn();
20
+ vi.mock("../utils/studioTelemetry", () => ({
21
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
22
+ }));
23
+ vi.mock("./timelineTrackVisibility", () => ({
24
+ reseekPreviewRuntime: (...args: unknown[]) => reseekPreviewRuntime(...args),
25
+ }));
26
+
27
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
28
+
29
+ const SOURCE = '<div data-hf-id="hf-card" style="color: red">Card</div>';
30
+ const PATCHED = '<div data-hf-id="hf-card" style="color: blue">Card</div>';
31
+ const OPERATIONS: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
32
+ const selection = {
33
+ hfId: "hf-card",
34
+ selector: '[data-hf-id="hf-card"]',
35
+ selectorIndex: 0,
36
+ sourceFile: "index.html",
37
+ } as DomEditSelection;
38
+
39
+ function stubServer(patchResponse: Record<string, unknown>) {
40
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
41
+ const url = requestUrl(input);
42
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: SOURCE });
43
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
44
+ return jsonResponse(patchResponse);
45
+ }
46
+ throw new Error(`Unexpected fetch: ${url}`);
47
+ });
48
+ vi.stubGlobal("fetch", fetchMock);
49
+ return fetchMock;
50
+ }
51
+
52
+ const CHANGED = { changed: true, content: PATCHED, path: "index.html", version: "v2" };
53
+ const PREPARED = PATCHED.replace("Card", "Card!");
54
+ const sdkResult = (result: CutoverResult) => vi.fn(async (): Promise<CutoverResult> => result);
55
+
56
+ const patchPosts = (fetchMock: ReturnType<typeof stubServer>) =>
57
+ fetchMock.mock.calls.filter(([input]) => requestUrl(input).includes("/patch-element/"));
58
+
59
+ let unmount: (() => void) | null = null;
60
+
61
+ function renderPersist(over: Partial<UseDomEditPersistParams> = {}) {
62
+ const params: UseDomEditPersistParams = {
63
+ activeCompPath: "index.html",
64
+ previewIframeRef: { current: null },
65
+ showToast: vi.fn(),
66
+ queueDomEditSave: (save) => save(),
67
+ writeProjectFile: vi.fn(async () => {}),
68
+ editHistory: { recordEdit: vi.fn(async () => {}) },
69
+ projectIdRef: { current: "p1" },
70
+ reloadPreview: vi.fn(),
71
+ forceReloadSdkSession: vi.fn(),
72
+ ...over,
73
+ };
74
+ const captured: { persist: PersistDomEditOperations | null } = { persist: null };
75
+ function Probe() {
76
+ captured.persist = useDomEditPersist(params);
77
+ return null;
78
+ }
79
+ const root = createRoot(document.createElement("div"));
80
+ act(() => root.render(createElement(Probe)));
81
+ unmount = () => act(() => root.unmount());
82
+ if (!captured.persist) throw new Error("hook did not initialize");
83
+ return { persist: captured.persist, params };
84
+ }
85
+
86
+ describe("useDomEditPersist", () => {
87
+ beforeEach(() => {
88
+ trackStudioEvent.mockReset();
89
+ reseekPreviewRuntime.mockReset();
90
+ });
91
+ afterEach(() => {
92
+ unmount?.();
93
+ unmount = null;
94
+ vi.unstubAllGlobals();
95
+ });
96
+
97
+ it("patches the source, records the edit and reloads the preview", async () => {
98
+ const fetchMock = stubServer({
99
+ changed: true,
100
+ content: PATCHED,
101
+ path: "index.html",
102
+ version: "v2",
103
+ });
104
+ const { persist, params } = renderPersist();
105
+
106
+ const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
107
+
108
+ expect(outcome).toEqual({ sourceFile: "index.html", version: "v2", changed: true });
109
+ const [, init] = patchPosts(fetchMock)[0]!;
110
+ expect(JSON.parse(String(init?.body))).toEqual({
111
+ target: { hfId: "hf-card", selector: '[data-hf-id="hf-card"]', selectorIndex: 0 },
112
+ operations: OPERATIONS,
113
+ });
114
+ expect(params.editHistory.recordEdit).toHaveBeenCalledWith({
115
+ label: "Recolor",
116
+ coalesceKey: undefined,
117
+ coalesceMs: undefined,
118
+ files: { "index.html": { before: SOURCE, after: PATCHED } },
119
+ });
120
+ expect(params.forceReloadSdkSession).toHaveBeenCalledTimes(1);
121
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
122
+ expect(reseekPreviewRuntime).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it("with skipRefresh reseeks the preview instead of reloading it", async () => {
126
+ stubServer({ changed: true, content: PATCHED, path: "index.html", version: "v2" });
127
+ const { persist, params } = renderPersist();
128
+
129
+ await persist(selection, OPERATIONS, { skipRefresh: true });
130
+
131
+ expect(params.reloadPreview).not.toHaveBeenCalled();
132
+ expect(reseekPreviewRuntime).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it("an unchanged patch records nothing and reports changed: false", async () => {
136
+ stubServer({ changed: false, matched: true, path: "index.html", version: "v1" });
137
+ const { persist, params } = renderPersist();
138
+
139
+ const outcome = await persist(selection, OPERATIONS);
140
+
141
+ expect(outcome).toEqual({ sourceFile: "index.html", version: "v1", changed: false });
142
+ expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
143
+ expect(params.reloadPreview).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("an unmatched target throws and is reported once per target", async () => {
147
+ stubServer({ changed: false, matched: false });
148
+ const { persist } = renderPersist();
149
+
150
+ await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
151
+ DomEditPersistUnresolvableError,
152
+ );
153
+ await expect(persist(selection, OPERATIONS)).rejects.toBeInstanceOf(
154
+ DomEditPersistUnresolvableError,
155
+ );
156
+
157
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
158
+ expect(trackStudioEvent).toHaveBeenCalledWith(
159
+ "save_skipped_unresolvable",
160
+ expect.objectContaining({ target_selector: '[data-hf-id="hf-card"]' }),
161
+ );
162
+ });
163
+
164
+ it("refuses a non-finite layout value before any request, with a toast", async () => {
165
+ const fetchMock = stubServer({ changed: true });
166
+ const { persist, params } = renderPersist();
167
+
168
+ await expect(
169
+ persist({ ...selection, selectorIndex: Number.NaN }, OPERATIONS),
170
+ ).rejects.toBeInstanceOf(DomEditPersistUnsafeValueError);
171
+
172
+ expect(fetchMock).not.toHaveBeenCalled();
173
+ expect(params.showToast).toHaveBeenCalledWith(
174
+ "Couldn't save edit because it contains invalid layout values",
175
+ "error",
176
+ );
177
+ });
178
+
179
+ it("a committed SDK cutover skips the server patch and the reload", async () => {
180
+ const fetchMock = stubServer({ changed: true });
181
+ const onTrySdkPersist = vi.fn(
182
+ async (): Promise<CutoverResult> => ({
183
+ status: "committed",
184
+ version: "sdk-3",
185
+ before: SOURCE,
186
+ after: PATCHED,
187
+ }),
188
+ );
189
+ const { persist, params } = renderPersist({ onTrySdkPersist });
190
+
191
+ const outcome = await persist(selection, OPERATIONS, { label: "Recolor" });
192
+
193
+ expect(outcome).toEqual({ sourceFile: "index.html", version: "sdk-3", changed: true });
194
+ expect(onTrySdkPersist).toHaveBeenCalledWith(selection, OPERATIONS, SOURCE, "index.html", {
195
+ label: "Recolor",
196
+ coalesceKey: undefined,
197
+ skipRefresh: undefined,
198
+ });
199
+ expect(patchPosts(fetchMock)).toHaveLength(0);
200
+ expect(params.reloadPreview).not.toHaveBeenCalled();
201
+ expect(params.forceReloadSdkSession).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("records the edit once, and reloads only after the record has settled", async () => {
205
+ stubServer(CHANGED);
206
+ let finishRecord = () => {};
207
+ const recordEdit = vi.fn(
208
+ () =>
209
+ new Promise<void>((resolve) => {
210
+ finishRecord = resolve;
211
+ }),
212
+ );
213
+ const { persist, params } = renderPersist({ editHistory: { recordEdit } });
214
+
215
+ const saving = persist(selection, OPERATIONS);
216
+ await vi.waitFor(() => expect(recordEdit).toHaveBeenCalledTimes(1));
217
+ expect(params.reloadPreview).not.toHaveBeenCalled();
218
+ finishRecord();
219
+ await saving;
220
+
221
+ expect(recordEdit).toHaveBeenCalledTimes(1);
222
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
223
+ });
224
+
225
+ it("writes prepared content after the patch and records it as the saved file", async () => {
226
+ stubServer(CHANGED);
227
+ const { persist, params } = renderPersist();
228
+
229
+ const outcome = await persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
230
+
231
+ expect(params.writeProjectFile).toHaveBeenCalledWith("index.html", PREPARED, PATCHED);
232
+ expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
233
+ expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PREPARED } } }),
234
+ );
235
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
236
+ expect(outcome).toBeUndefined();
237
+ });
238
+
239
+ it("a failed prepared write keeps the patch, reloads, then rejects", async () => {
240
+ stubServer(CHANGED);
241
+ const { persist, params } = renderPersist({
242
+ writeProjectFile: vi.fn(async () => {
243
+ throw new Error("disk full");
244
+ }),
245
+ });
246
+
247
+ const saving = persist(selection, OPERATIONS, { prepareContent: () => PREPARED });
248
+
249
+ await expect(saving).rejects.toBeInstanceOf(DomEditPersistPreparedWriteError);
250
+ expect(params.editHistory.recordEdit).toHaveBeenCalledWith(
251
+ expect.objectContaining({ files: { "index.html": { before: SOURCE, after: PATCHED } } }),
252
+ );
253
+ expect(params.reloadPreview).toHaveBeenCalledTimes(1);
254
+ expect(params.showToast).toHaveBeenCalledWith(
255
+ "Saved, but couldn't finish updating index.html: disk full",
256
+ "error",
257
+ );
258
+ });
259
+
260
+ it("a declined SDK cutover falls back to the server patch", async () => {
261
+ const fetchMock = stubServer(CHANGED);
262
+ const onTrySdkPersist = sdkResult({ status: "declined", reason: "unsupported" });
263
+ const { persist, params } = renderPersist({ onTrySdkPersist });
264
+
265
+ await persist(selection, OPERATIONS);
266
+
267
+ expect(onTrySdkPersist).toHaveBeenCalledTimes(1);
268
+ expect(patchPosts(fetchMock)).toHaveLength(1);
269
+ expect(params.editHistory.recordEdit).toHaveBeenCalledTimes(1);
270
+ });
271
+
272
+ it("a failed SDK cutover rethrows without a server patch", async () => {
273
+ const fetchMock = stubServer(CHANGED);
274
+ const error = new Error("sdk broke");
275
+ const { persist } = renderPersist({ onTrySdkPersist: sdkResult({ status: "failed", error }) });
276
+
277
+ await expect(persist(selection, OPERATIONS)).rejects.toBe(error);
278
+ expect(patchPosts(fetchMock)).toHaveLength(0);
279
+ });
280
+
281
+ it("an imported font or prepared content skips the SDK and goes to the server", async () => {
282
+ const fetchMock = stubServer(CHANGED);
283
+ const onTrySdkPersist = sdkResult({ status: "declined", reason: "unused" });
284
+ const { persist } = renderPersist({ onTrySdkPersist });
285
+ const importedFont = { family: "Inter", path: "fonts/inter.woff2", url: "/fonts/inter.woff2" };
286
+
287
+ await persist(selection, OPERATIONS, { importedFont });
288
+ await persist(selection, OPERATIONS, { prepareContent: (html) => html });
289
+
290
+ expect(onTrySdkPersist).not.toHaveBeenCalled();
291
+ const [[, fontInit]] = patchPosts(fetchMock);
292
+ expect(JSON.parse(String(fontInit?.body)).fontFaceCss).toContain("Inter");
293
+ expect(patchPosts(fetchMock)).toHaveLength(2);
294
+ });
295
+
296
+ it("counts a save on the edited element's document until it settles", async () => {
297
+ stubServer(CHANGED);
298
+ const element = document.createElement("div");
299
+ const { persist } = renderPersist();
300
+ const before = studioManualEditSavesIn(document);
301
+
302
+ const saving = persist({ ...selection, element }, OPERATIONS);
303
+ expect(studioManualEditSavesIn(document)).toBe(before + 1);
304
+ await saving;
305
+
306
+ expect(studioManualEditSavesIn(document)).toBe(before + 2);
307
+ });
308
+
309
+ it("does nothing when shouldSave says no", async () => {
310
+ const fetchMock = stubServer({ changed: true });
311
+ const { persist } = renderPersist();
312
+
313
+ await expect(persist(selection, OPERATIONS, { shouldSave: () => false })).resolves.toBe(
314
+ undefined,
315
+ );
316
+ expect(fetchMock).not.toHaveBeenCalled();
317
+ });
318
+
319
+ it("refuses to save once the active project has changed", async () => {
320
+ stubServer({ changed: true });
321
+ const projectIdRef = { current: "p1" as string | null };
322
+ const { persist, params } = renderPersist({
323
+ projectIdRef,
324
+ queueDomEditSave: async (save) => {
325
+ projectIdRef.current = "p2";
326
+ return save();
327
+ },
328
+ });
329
+
330
+ await expect(persist(selection, OPERATIONS)).rejects.toThrow(
331
+ "Active project changed before the edit could be saved",
332
+ );
333
+ expect(params.editHistory.recordEdit).not.toHaveBeenCalled();
334
+ });
335
+ });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act, createElement, useRef } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
5
5
  import {
6
6
  useDomGeometryCommit,
7
7
  usePlayerStore,
@@ -108,6 +108,10 @@ afterEach(() => {
108
108
  });
109
109
 
110
110
  describe("useDomGeometryCommit, from the package entry", () => {
111
+ it("takes the host's own preview reload, with no fallback", () => {
112
+ expectTypeOf<UseDomGeometryCommitOptions["reloadPreview"]>().toEqualTypeOf<() => void>();
113
+ });
114
+
111
115
  it("plugs into DomEditOverlay and saves a move as one GSAP write and one undo step", async () => {
112
116
  const mutations = stubServer();
113
117
  const { element, recordEdit, hook, unmount } = renderHost();
@@ -26,8 +26,8 @@ import { useMountEffect } from "./useMountEffect";
26
26
  * mounts; drain `waitForPendingSaves` before switching projects.
27
27
  */
28
28
  export interface UseDomGeometryCommitOptions extends UseDomStyleCommitOptions {
29
- /** Called when a save cannot patch the preview in place; defaults to reloading the iframe. */
30
- reloadPreview?: () => void;
29
+ /** Reloads the host's preview when a save cannot patch it in place. */
30
+ reloadPreview: () => void;
31
31
  }
32
32
 
33
33
  export interface DomGeometryCommits {
@@ -42,6 +42,7 @@ export interface DomGeometryCommits {
42
42
  next: { width: number; height: number },
43
43
  offset?: { x: number; y: number },
44
44
  restore?: () => void,
45
+ route?: { plainTranslate: boolean },
45
46
  ) => Promise<DomEditCommitOutcome>;
46
47
  commitRotation: (
47
48
  selection: DomEditSelection,
@@ -70,17 +71,13 @@ export function useDomGeometryCommit({
70
71
  projectIdRef.current = projectId;
71
72
  const pending = useRef(new Set<Promise<GeometryCommitResult | undefined>>()).current;
72
73
  const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
73
- const reload = useCallback(
74
- () => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
75
- [reloadPreview, iframeRef],
76
- );
77
74
  const { bump: bumpGsapCache } = useGsapCacheVersion();
78
75
  const gsap = useGsapScriptCommits({
79
76
  projectIdRef,
80
77
  activeCompPath,
81
78
  previewIframeRef: iframeRef,
82
79
  editHistory,
83
- reloadPreview: reload,
80
+ reloadPreview,
84
81
  onCacheInvalidate: bumpGsapCache,
85
82
  showToast,
86
83
  writeProjectFile,
@@ -174,8 +171,8 @@ export function useDomGeometryCommit({
174
171
  saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)),
175
172
  commitGroupPathOffset: (updates) =>
176
173
  saved(() => handleGsapAwareGroupPathOffsetCommit(updates)),
177
- commitBoxSize: (selection, next, offset, restore) =>
178
- saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore),
174
+ commitBoxSize: (selection, next, offset, restore, route) =>
175
+ saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore, route), restore),
179
176
  commitRotation: (selection, next) =>
180
177
  saved(() => handleGsapAwareRotationCommit(selection, next)),
181
178
  waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop),
@@ -1,6 +1,9 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
1
4
  import { afterEach, describe, expect, it, vi } from "vitest";
2
5
  import type { TimelineElement } from "../player";
3
- import { requestFreezeFrame } from "./useFreezeFrame";
6
+ import { requestFreezeFrame, useFreezeFrame } from "./useFreezeFrame";
4
7
 
5
8
  afterEach(() => vi.unstubAllGlobals());
6
9
 
@@ -29,14 +32,15 @@ function stubFetch(freezeResponse: { status: number; body: unknown }) {
29
32
 
30
33
  describe("requestFreezeFrame", () => {
31
34
  it("posts the clip target, file version and an authored-time playhead", async () => {
32
- const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
35
+ const frozen = { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/t.png" };
36
+ const calls = stubFetch({ status: 200, body: frozen });
33
37
  const result = await requestFreezeFrame({
34
38
  projectId: "p",
35
39
  path: "scene.html",
36
40
  element,
37
41
  playhead: 7.5,
38
42
  });
39
- expect(result).toEqual({ before: "a", after: "b", version: "v2" });
43
+ expect(result).toEqual(frozen);
40
44
  const post = calls[1];
41
45
  expect(post?.url).toContain("/file-mutations/freeze-frame");
42
46
  expect(post?.body).toMatchObject({
@@ -54,3 +58,35 @@ describe("requestFreezeFrame", () => {
54
58
  ).rejects.toThrow("Move the playhead inside a video clip to freeze");
55
59
  });
56
60
  });
61
+
62
+ describe("useFreezeFrame", () => {
63
+ it("records the still it made with the edit, so Undo removes it", async () => {
64
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65
+ stubFetch({
66
+ status: 200,
67
+ body: { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/talk.png" },
68
+ });
69
+ const recordEdit = vi.fn(async () => {});
70
+ let freeze!: ReturnType<typeof useFreezeFrame>;
71
+ function Harness() {
72
+ freeze = useFreezeFrame({
73
+ projectId: "p",
74
+ activeCompPath: "index.html",
75
+ showToast: () => {},
76
+ writeProjectFile: async () => {},
77
+ recordEdit,
78
+ reloadPreview: () => {},
79
+ });
80
+ return null;
81
+ }
82
+ const root = createRoot(document.createElement("div"));
83
+ await act(async () => root.render(createElement(Harness)));
84
+ await act(() => freeze({ ...element, sourceFile: "index.html" }, 7.5));
85
+ expect(recordEdit).toHaveBeenCalledWith({
86
+ label: "Freeze frame",
87
+ files: { "index.html": { before: "a", after: "b" } },
88
+ created: ["assets/freeze/talk.png"],
89
+ });
90
+ act(() => root.unmount());
91
+ });
92
+ });
@@ -14,6 +14,7 @@ interface FreezeFrameResponse {
14
14
  before: string;
15
15
  after: string;
16
16
  version: string;
17
+ stillPath: string;
17
18
  }
18
19
 
19
20
  function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
@@ -22,7 +23,8 @@ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
22
23
  return (
23
24
  typeof body.before === "string" &&
24
25
  typeof body.after === "string" &&
25
- typeof body.version === "string"
26
+ typeof body.version === "string" &&
27
+ typeof body.stillPath === "string"
26
28
  );
27
29
  }
28
30
 
@@ -109,6 +111,7 @@ export function useFreezeFrame(options: UseFreezeFrameOptions) {
109
111
  await opts.recordEdit({
110
112
  label: "Freeze frame",
111
113
  files: { [path]: { before: result.before, after: result.after } },
114
+ created: [result.stillPath],
112
115
  });
113
116
  opts.observeProjectFileVersion?.(path, result.version);
114
117
  });