@hyperframes/studio 0.8.116 → 0.8.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/assets/{hyperframes-player-mGYvPVpw.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-DYNaXPVf.js → index-C5qKdpgl.js} +178 -176
  3. package/dist/assets/{index-BWHt0-ir.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-Dr3hNpvS.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/index.d.ts +28 -19
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +77 -64
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditOverlay.tsx +1 -0
  11. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  12. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  13. package/src/components/editor/manualOffsetDrag.ts +3 -3
  14. package/src/components/editor/plainTranslate.ts +5 -3
  15. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  16. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  17. package/src/components/editor/rotationDraft.ts +2 -1
  18. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  19. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  20. package/src/hooks/gsapScriptCommitTypes.ts +1 -1
  21. package/src/hooks/plainRotation.ts +5 -1
  22. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  23. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  24. package/src/hooks/useDomGeometryCommit.ts +6 -9
  25. package/src/hooks/useFreezeFrame.test.ts +39 -3
  26. package/src/hooks/useFreezeFrame.ts +4 -1
  27. package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
  28. package/src/hooks/useGsapAwareEditing.ts +12 -8
  29. package/src/hooks/useGsapAwareGroupMove.ts +2 -2
  30. package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
  31. package/src/hooks/useGsapScriptCommits.ts +9 -7
  32. package/src/hooks/usePersistentEditHistory.test.ts +25 -2
  33. package/src/hooks/usePersistentEditHistory.ts +3 -9
  34. package/src/utils/gsapSoftReload.test.ts +39 -0
  35. package/src/utils/gsapSoftReload.ts +7 -11
  36. package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
  37. package/src/utils/softReloadTargets.ts +4 -3
  38. package/src/utils/studioFileHistory.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.116",
3
+ "version": "0.8.117",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.116",
64
- "@hyperframes/player": "0.8.116",
65
- "@hyperframes/core": "0.8.116",
66
- "@hyperframes/sdk": "0.8.116",
67
- "@hyperframes/studio-server": "0.8.116",
68
- "@hyperframes/shader-transitions": "0.8.116"
63
+ "@hyperframes/core": "0.8.117",
64
+ "@hyperframes/parsers": "0.8.117",
65
+ "@hyperframes/sdk": "0.8.117",
66
+ "@hyperframes/shader-transitions": "0.8.117",
67
+ "@hyperframes/studio-server": "0.8.117",
68
+ "@hyperframes/player": "0.8.117"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.116"
84
+ "@hyperframes/producer": "0.8.117"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -94,6 +94,7 @@ export interface DomEditOverlayProps {
94
94
  next: { width: number; height: number },
95
95
  offset?: { x: number; y: number },
96
96
  restore?: () => void,
97
+ route?: { plainTranslate: boolean },
97
98
  ) => Promise<unknown> | void;
98
99
  onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
99
100
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
@@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = {
275
275
  n: { width: number; height: number },
276
276
  offset?: { x: number; y: number },
277
277
  restore?: () => void,
278
+ route?: { plainTranslate: boolean },
278
279
  ) => Promise<unknown> | void
279
280
  >;
280
281
  onRotationCommitRef: RefObject<
@@ -32,7 +32,7 @@ import {
32
32
  type UseDomEditOverlayGesturesOptions,
33
33
  } from "./domEditOverlayGestures";
34
34
  import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
35
- import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
35
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
36
36
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
37
37
  import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
38
38
 
@@ -234,7 +234,7 @@ export function startGesture(
234
234
  try {
235
235
  // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
236
236
  // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
237
- const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
237
+ const plain = !!pathOffsetMember?.plainTranslate || editsPlainCss(sel.element, "rotate");
238
238
  const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
239
239
  const rotation = { angle: readRotationBase(sel.element, plain) };
240
240
  const overlayBounds = overlayEl?.getBoundingClientRect();
@@ -11,7 +11,7 @@ import {
11
11
  type StudioPathOffsetSnapshot,
12
12
  } from "./manualEdits";
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
- import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
14
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
16
  import type { StudioEditRevert } from "../../utils/studioPendingEdits";
17
17
 
@@ -318,8 +318,8 @@ export function createManualOffsetDragMember(input: {
318
318
  rect: ManualOffsetDragRect;
319
319
  gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
320
320
  }): ManualOffsetDragMemberResult {
321
- const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
322
- const plainTranslate = !!input.gesture && !gsapOwns(input.element);
321
+ const plainTranslate =
322
+ !!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
323
323
  // The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
324
324
  const initialOffset = plainTranslate
325
325
  ? readTranslatePx(input.element)
@@ -1,5 +1,5 @@
1
1
  import { roundTo3 } from "../../utils/rounding";
2
- import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
2
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
3
3
  import { readCssRotation } from "../../hooks/draggedGsapPosition";
4
4
  import { readStudioPathOffset, readStudioRotation, styleUsesStudioOffset } from "./manualEditsDom";
5
5
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
@@ -142,10 +142,12 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
142
142
  }
143
143
 
144
144
  /** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
145
- export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
145
+ export function readMoveOffset(el: HTMLElement, plainTranslate = editsPlainCss(el, "move")): Point {
146
146
  return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
147
147
  }
148
148
 
149
149
  export function readShownRotation(el: HTMLElement): { angle: number } {
150
- return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) };
150
+ return editsPlainCss(el, "rotate")
151
+ ? { angle: roundTo3(readCssRotation(el)) }
152
+ : readStudioRotation(el);
151
153
  }
@@ -9,7 +9,7 @@ import {
9
9
  playbackStartAttributeForElement,
10
10
  readPlaybackStartAttributes,
11
11
  } from "../../player/lib/timelineElementHelpers";
12
- import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
12
+ import { editsPlainCss, GSAP_TRANSFORM_KEYS } from "../../hooks/gsapRuntimeKeyframes";
13
13
 
14
14
  export type {
15
15
  BackgroundRemovalProgress,
@@ -496,7 +496,7 @@ export function readGsapRuntimeValuesForPanel(
496
496
  const el = findPreviewNode(iframe.contentDocument, element);
497
497
  if (!el) return null;
498
498
  const propKeys = collectPanelPropKeys(gsapAnimations);
499
- const readsTransform = gsapWritesPosition(el);
499
+ const readsTransform = !editsPlainCss(el, "move");
500
500
  const result: Record<string, number> = {};
501
501
  for (const prop of propKeys) {
502
502
  if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
@@ -1,4 +1,4 @@
1
- import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
2
2
  import type { DomEditSelection } from "./domEditingTypes";
3
3
  import { readStudioBoxSize } from "./manualEdits";
4
4
  import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
@@ -75,7 +75,7 @@ export function createTransformCommitHandlers({
75
75
  )
76
76
  )
77
77
  return;
78
- const plainTranslate = !gsapWritesPosition(element.element);
78
+ const plainTranslate = editsPlainCss(element.element, "move");
79
79
  const current = readMoveOffset(element.element, plainTranslate);
80
80
  if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
81
81
  return void showToast?.(UNREADABLE_TRANSLATE);
@@ -51,7 +51,8 @@ export interface CssRotationTarget {
51
51
  inline: boolean;
52
52
  }
53
53
 
54
- export type RotationCommit = { angle: number; plain?: CssRotationTarget };
54
+ export type PressChoseGsap = null;
55
+ export type RotationCommit = { angle: number; plain?: CssRotationTarget | PressChoseGsap };
55
56
 
56
57
  const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
57
58
  const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
@@ -442,7 +442,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
442
442
  }
443
443
  // Hold the final angle while the commit lands.
444
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
445
- const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
445
+ const commit = { ...finalRotation, plain: g.plainRotation };
446
446
  void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
447
447
  .then((result) => trackPreviewEditResult("rotate", "drag", result))
448
448
  .catch((error) => {
@@ -536,7 +536,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
536
536
  if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
537
537
  };
538
538
  void Promise.resolve(
539
- opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
539
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
540
540
  )
541
541
  .then((result) => trackPreviewEditResult("resize", "drag", result))
542
542
  .catch((error) => {
@@ -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, {
@@ -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),