@hyperframes/studio 0.6.95 → 0.6.97

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 (50) hide show
  1. package/dist/assets/hyperframes-player-Daj5djxa.js +418 -0
  2. package/dist/assets/index-B0twsRu0.css +1 -0
  3. package/dist/assets/index-Cfye9xzo.js +251 -0
  4. package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/App.tsx +10 -5
  8. package/src/components/SaveQueuePausedBanner.tsx +23 -0
  9. package/src/components/StudioPreviewArea.tsx +7 -0
  10. package/src/components/StudioRightPanel.tsx +1 -38
  11. package/src/components/editor/DomEditOverlay.test.ts +169 -29
  12. package/src/components/editor/DomEditOverlay.tsx +13 -23
  13. package/src/components/editor/GestureRecordControl.tsx +98 -0
  14. package/src/components/editor/PropertyPanel.tsx +22 -38
  15. package/src/components/editor/domEditing.test.ts +84 -0
  16. package/src/components/editor/domEditingLayers.ts +19 -0
  17. package/src/components/editor/domEditingRootLayer.ts +64 -0
  18. package/src/components/editor/manualEditingAvailability.test.ts +1 -2
  19. package/src/components/editor/manualEditingAvailability.ts +0 -7
  20. package/src/contexts/DomEditContext.tsx +1 -6
  21. package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
  22. package/src/hooks/useDomEditCommits.ts +97 -123
  23. package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
  24. package/src/hooks/useDomEditSession.ts +59 -65
  25. package/src/hooks/useFileManager.ts +19 -5
  26. package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
  27. package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
  28. package/src/hooks/useGsapScriptCommits.ts +152 -140
  29. package/src/hooks/useGsapSelectionHandlers.ts +38 -8
  30. package/src/hooks/usePreviewPersistence.ts +90 -51
  31. package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
  32. package/src/hooks/useStudioContextValue.ts +3 -19
  33. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  34. package/src/player/lib/playbackAdapter.test.ts +86 -1
  35. package/src/player/lib/playbackAdapter.ts +62 -0
  36. package/src/utils/domEditSaveQueue.test.ts +117 -0
  37. package/src/utils/domEditSaveQueue.ts +87 -0
  38. package/src/utils/studioHelpers.ts +1 -1
  39. package/src/utils/studioSaveDiagnostics.test.ts +127 -0
  40. package/src/utils/studioSaveDiagnostics.ts +200 -0
  41. package/src/utils/studioUrlState.test.ts +0 -1
  42. package/src/utils/studioUrlState.ts +2 -8
  43. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  44. package/dist/assets/index-DujOjou6.js +0 -251
  45. package/dist/assets/index-rm9tn9nH.css +0 -1
  46. package/src/components/editor/EaseCurveEditor.tsx +0 -221
  47. package/src/components/editor/MotionPanel.tsx +0 -277
  48. package/src/components/editor/MotionPanelFields.tsx +0 -185
  49. package/src/components/editor/MotionPathOverlay.tsx +0 -146
  50. package/src/components/editor/SpringEaseEditor.tsx +0 -256
@@ -21,6 +21,7 @@ import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEdi
21
21
  import { usePlayerStore, liveTime } from "../../player";
22
22
  import { TimingSection } from "./propertyPanelTimingSection";
23
23
  import { type PropertyPanelProps } from "./propertyPanelHelpers";
24
+ import { GestureRecordPanelButton } from "./GestureRecordControl";
24
25
 
25
26
  // Re-export helpers that external consumers import from this module
26
27
  export {
@@ -34,10 +35,6 @@ export {
34
35
  setCssFilterFunctionPx,
35
36
  } from "./propertyPanelHelpers";
36
37
 
37
- /* ------------------------------------------------------------------ */
38
- /* PropertyPanel */
39
- /* ------------------------------------------------------------------ */
40
-
41
38
  // fallow-ignore-next-line complexity
42
39
  export const PropertyPanel = memo(function PropertyPanel({
43
40
  projectId,
@@ -177,10 +174,12 @@ export const PropertyPanel = memo(function PropertyPanel({
177
174
  return;
178
175
  }
179
176
  const current = readStudioPathOffset(element.element);
180
- onSetManualOffset(element, {
181
- x: axis === "x" ? parsed : current.x,
182
- y: axis === "y" ? parsed : current.y,
183
- });
177
+ void Promise.resolve(
178
+ onSetManualOffset(element, {
179
+ x: axis === "x" ? parsed : current.x,
180
+ y: axis === "y" ? parsed : current.y,
181
+ }),
182
+ ).catch(() => undefined);
184
183
  };
185
184
 
186
185
  // fallow-ignore-next-line complexity
@@ -204,17 +203,19 @@ export const PropertyPanel = memo(function PropertyPanel({
204
203
  current.height > 0
205
204
  ? current.height
206
205
  : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
207
- onSetManualSize(element, {
208
- width: axis === "width" ? parsed : width,
209
- height: axis === "height" ? parsed : height,
210
- });
206
+ void Promise.resolve(
207
+ onSetManualSize(element, {
208
+ width: axis === "width" ? parsed : width,
209
+ height: axis === "height" ? parsed : height,
210
+ }),
211
+ ).catch(() => undefined);
211
212
  };
212
213
 
213
214
  const manualRotation = readStudioRotation(element.element);
214
215
  const commitManualRotation = (nextValue: string) => {
215
216
  const parsed = Number.parseFloat(nextValue);
216
217
  if (!Number.isFinite(parsed)) return;
217
- onSetManualRotation(element, { angle: parsed });
218
+ void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
218
219
  };
219
220
 
220
221
  const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
@@ -354,6 +355,14 @@ export const PropertyPanel = memo(function PropertyPanel({
354
355
  </div>
355
356
  </div>
356
357
  <div className="flex-1 overflow-y-auto">
358
+ {onToggleRecording && (
359
+ <GestureRecordPanelButton
360
+ recordingState={recordingState}
361
+ recordingDuration={recordingDuration}
362
+ onToggleRecording={onToggleRecording}
363
+ />
364
+ )}
365
+
357
366
  <TextSection
358
367
  element={element}
359
368
  styles={styles}
@@ -558,31 +567,6 @@ export const PropertyPanel = memo(function PropertyPanel({
558
567
  />
559
568
  )}
560
569
 
561
- {onToggleRecording && (
562
- <div className="px-4 pb-3">
563
- <button
564
- type="button"
565
- onMouseDown={(e) => e.preventDefault()}
566
- onClick={onToggleRecording}
567
- className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
568
- recordingState === "recording"
569
- ? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
570
- : "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
571
- }`}
572
- >
573
- <svg width="10" height="10" viewBox="0 0 10 10">
574
- {recordingState === "recording" ? (
575
- <rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
576
- ) : (
577
- <circle cx="5" cy="5" r="4.5" fill="currentColor" />
578
- )}
579
- </svg>
580
- {recordingState === "recording"
581
- ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
582
- : "Record gesture (R) — move pointer to capture motion"}
583
- </button>
584
- </div>
585
- )}
586
570
  {showEditableSections && (
587
571
  <StyleSections
588
572
  projectId={projectId}
@@ -431,6 +431,90 @@ describe("resolveDomEditSelection", () => {
431
431
  });
432
432
  });
433
433
 
434
+ it("keeps the full-canvas stage layer transform disabled while allowing style edits", async () => {
435
+ const document = createDocument(`
436
+ <div data-hf-id="hf-stage" id="stage">
437
+ <button id="cta">Add to basket</button>
438
+ </div>
439
+ `);
440
+ document.documentElement.setAttribute("data-composition-id", "root");
441
+ document.documentElement.setAttribute("data-width", "1920");
442
+ document.documentElement.setAttribute("data-height", "1080");
443
+ setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 });
444
+ const stage = document.getElementById("stage") as HTMLElement;
445
+ setElementRect(stage, { left: 0, top: 0, width: 1920, height: 1080 });
446
+
447
+ const selection = await resolveDomEditSelection(stage, {
448
+ activeCompositionPath: null,
449
+ isMasterView: true,
450
+ skipSourceProbe: true,
451
+ });
452
+
453
+ expect(selection?.id).toBe("stage");
454
+ expect(selection?.capabilities).toMatchObject({
455
+ canSelect: true,
456
+ canEditStyles: true,
457
+ canMove: false,
458
+ canResize: false,
459
+ canApplyManualOffset: false,
460
+ canApplyManualSize: false,
461
+ canApplyManualRotation: false,
462
+ reasonIfDisabled: "The root composition defines the preview bounds.",
463
+ });
464
+ });
465
+
466
+ it("keeps direct full-bleed absolute layers editable", async () => {
467
+ const document = createDocument(`
468
+ <div id="hero" style="position: absolute; left: 0; top: 0; width: 1920px; height: 1080px;"></div>
469
+ `);
470
+ document.documentElement.setAttribute("data-composition-id", "root");
471
+ document.documentElement.setAttribute("data-width", "1920");
472
+ document.documentElement.setAttribute("data-height", "1080");
473
+ setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 });
474
+ const hero = document.getElementById("hero") as HTMLElement;
475
+ setElementRect(hero, { left: 0, top: 0, width: 1920, height: 1080 });
476
+
477
+ const selection = await resolveDomEditSelection(hero, {
478
+ activeCompositionPath: null,
479
+ isMasterView: true,
480
+ skipSourceProbe: true,
481
+ });
482
+
483
+ expect(selection?.id).toBe("hero");
484
+ expect(selection?.capabilities).toMatchObject({
485
+ canSelect: true,
486
+ canEditStyles: true,
487
+ canMove: true,
488
+ canResize: true,
489
+ canApplyManualOffset: true,
490
+ canApplyManualSize: true,
491
+ canApplyManualRotation: true,
492
+ });
493
+ });
494
+
495
+ it("lets full-canvas layers opt out of root-layer classification", async () => {
496
+ const document = createDocument(`
497
+ <div data-hf-allow-root-edit id="editable-stage">
498
+ <button id="cta">Add to basket</button>
499
+ </div>
500
+ `);
501
+ document.documentElement.setAttribute("data-composition-id", "root");
502
+ document.documentElement.setAttribute("data-width", "1920");
503
+ document.documentElement.setAttribute("data-height", "1080");
504
+ setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 });
505
+ const editableStage = document.getElementById("editable-stage") as HTMLElement;
506
+ setElementRect(editableStage, { left: 0, top: 0, width: 1920, height: 1080 });
507
+
508
+ const selection = await resolveDomEditSelection(editableStage, {
509
+ activeCompositionPath: null,
510
+ isMasterView: true,
511
+ skipSourceProbe: true,
512
+ });
513
+
514
+ expect(selection?.id).toBe("editable-stage");
515
+ expect(selection?.capabilities.canApplyManualOffset).toBe(true);
516
+ });
517
+
434
518
  it("resolves child clicks inside a composition host to the child in master view", async () => {
435
519
  const document = createDocument(`
436
520
  <div data-composition-id="main">
@@ -31,6 +31,7 @@ import {
31
31
  getDirectLayerChildren,
32
32
  getSelectionCandidate,
33
33
  } from "./domEditingElement";
34
+ import { isCompositionRootLayer } from "./domEditingRootLayer";
34
35
 
35
36
  // ─── Text fields ────────────────────────────────────────────────────────────
36
37
 
@@ -179,6 +180,7 @@ export function resolveDomEditCapabilities(args: {
179
180
  inlineStyles: Record<string, string>;
180
181
  computedStyles: Record<string, string>;
181
182
  isCompositionHost: boolean;
183
+ isCompositionRoot?: boolean;
182
184
  isInsideLockedComposition: boolean;
183
185
  isMasterView: boolean;
184
186
  existsInSource?: boolean;
@@ -211,6 +213,19 @@ export function resolveDomEditCapabilities(args: {
211
213
  };
212
214
  }
213
215
 
216
+ if (args.isCompositionRoot) {
217
+ return {
218
+ canSelect: true,
219
+ canEditStyles: true,
220
+ canMove: false,
221
+ canResize: false,
222
+ canApplyManualOffset: false,
223
+ canApplyManualSize: false,
224
+ canApplyManualRotation: false,
225
+ reasonIfDisabled: "The root composition defines the preview bounds.",
226
+ };
227
+ }
228
+
214
229
  const position = args.computedStyles.position;
215
230
  const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
216
231
  const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
@@ -341,6 +356,9 @@ export async function resolveDomEditSelection(
341
356
  undefined;
342
357
  const inlineStyles = getInlineStyles(current);
343
358
  const computedStyles = getCuratedComputedStyles(current);
359
+ const isCompositionRoot =
360
+ (current.hasAttribute("data-composition-id") && !compositionSrc) ||
361
+ isCompositionRootLayer(current, doc, computedStyles);
344
362
  const textFields = collectDomEditTextFields(current);
345
363
  const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
346
364
  let existsInSource: boolean | undefined;
@@ -361,6 +379,7 @@ export async function resolveDomEditSelection(
361
379
  inlineStyles,
362
380
  computedStyles,
363
381
  isCompositionHost: Boolean(compositionSrc),
382
+ isCompositionRoot,
364
383
  isInsideLockedComposition: isInsideLocked,
365
384
  isMasterView: options.isMasterView,
366
385
  existsInSource,
@@ -0,0 +1,64 @@
1
+ import { parsePx } from "./domEditingDom";
2
+
3
+ const COMPOSITION_ROOT_LAYER_EPSILON_PX = 1;
4
+
5
+ function readPositiveDimension(value: string | null): number | null {
6
+ if (!value) return null;
7
+ const parsed = Number.parseFloat(value);
8
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
9
+ }
10
+
11
+ function approximatelyEqual(a: number, b: number) {
12
+ return Math.abs(a - b) <= COMPOSITION_ROOT_LAYER_EPSILON_PX;
13
+ }
14
+
15
+ function getCompositionRootBounds(doc: Document) {
16
+ const root =
17
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
18
+ const rootWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
19
+ const rootHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
20
+ if (!root || !rootWidth || !rootHeight) return null;
21
+ return { rect: root.getBoundingClientRect(), width: rootWidth, height: rootHeight };
22
+ }
23
+
24
+ function getRenderedLayerSize(element: HTMLElement, computedStyles: Record<string, string>) {
25
+ const rect = element.getBoundingClientRect();
26
+ const width = rect.width || parsePx(computedStyles.width);
27
+ const height = rect.height || parsePx(computedStyles.height);
28
+ return width && height ? { width, height } : null;
29
+ }
30
+
31
+ function matchesCompositionRootBounds(
32
+ elementRect: DOMRect,
33
+ elementSize: { width: number; height: number },
34
+ rootBounds: { rect: DOMRect; width: number; height: number },
35
+ ) {
36
+ return (
37
+ approximatelyEqual(elementRect.left, rootBounds.rect.left) &&
38
+ approximatelyEqual(elementRect.top, rootBounds.rect.top) &&
39
+ approximatelyEqual(elementSize.width, rootBounds.width) &&
40
+ approximatelyEqual(elementSize.height, rootBounds.height)
41
+ );
42
+ }
43
+
44
+ function isExplicitFullBleedLayer(computedStyles: Record<string, string>) {
45
+ return computedStyles.position === "absolute" || computedStyles.position === "fixed";
46
+ }
47
+
48
+ export function isCompositionRootLayer(
49
+ element: HTMLElement,
50
+ doc: Document,
51
+ computedStyles: Record<string, string>,
52
+ ) {
53
+ if (element.parentElement !== doc.body) return false;
54
+ if (element.hasAttribute("data-hf-allow-root-edit")) return false;
55
+ if (isExplicitFullBleedLayer(computedStyles)) return false;
56
+
57
+ const rootBounds = getCompositionRootBounds(doc);
58
+ const elementSize = getRenderedLayerSize(element, computedStyles);
59
+ return Boolean(
60
+ rootBounds &&
61
+ elementSize &&
62
+ matchesCompositionRootBounds(element.getBoundingClientRect(), elementSize, rootBounds),
63
+ );
64
+ }
@@ -16,13 +16,12 @@ describe("manual editing availability", () => {
16
16
  vi.resetModules();
17
17
  });
18
18
 
19
- it("enables inspector selection and manual dragging by default while motion stays opt-in", async () => {
19
+ it("enables inspector selection and manual dragging by default", async () => {
20
20
  const availability = await loadAvailabilityWithEnv({});
21
21
 
22
22
  expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(true);
23
23
  expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true);
24
24
  expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
25
- expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
26
25
  });
27
26
 
28
27
  it("enables GSAP drag intercept by default", async () => {
@@ -2,7 +2,6 @@ export type StudioFeatureFlagEnv = Record<string, boolean | string | undefined>;
2
2
 
3
3
  const STUDIO_PREVIEW_MANUAL_DRAGGING_ENV = "VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING";
4
4
  const STUDIO_INSPECTOR_PANELS_ENV = "VITE_STUDIO_ENABLE_INSPECTOR_PANELS";
5
- const STUDIO_MOTION_PANEL_ENV = "VITE_STUDIO_ENABLE_MOTION_PANEL";
6
5
  const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
7
6
  const FALSY_ENV_VALUES = new Set(["0", "false", "no", "off", "disabled"]);
8
7
 
@@ -53,12 +52,6 @@ export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
53
52
  true,
54
53
  );
55
54
 
56
- export const STUDIO_MOTION_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
57
- env,
58
- [STUDIO_MOTION_PANEL_ENV, "VITE_STUDIO_MOTION_PANEL_ENABLED"],
59
- false,
60
- );
61
-
62
55
  export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
63
56
  env,
64
57
  ["VITE_STUDIO_ENABLE_BLOCKS_PANEL", "VITE_STUDIO_BLOCKS_PANEL_ENABLED"],
@@ -37,8 +37,7 @@ export function DomEditProvider({
37
37
  handleDomBoxSizeCommit,
38
38
  handleDomRotationCommit,
39
39
  handleDomManualEditsReset,
40
- handleDomMotionCommit,
41
- handleDomMotionClear,
40
+
42
41
  handleDomTextCommit,
43
42
  handleDomTextFieldStyleCommit,
44
43
  handleDomAddTextField,
@@ -111,8 +110,6 @@ export function DomEditProvider({
111
110
  handleDomBoxSizeCommit,
112
111
  handleDomRotationCommit,
113
112
  handleDomManualEditsReset,
114
- handleDomMotionCommit,
115
- handleDomMotionClear,
116
113
  handleDomTextCommit,
117
114
  handleDomTextFieldStyleCommit,
118
115
  handleDomAddTextField,
@@ -179,8 +176,6 @@ export function DomEditProvider({
179
176
  handleDomBoxSizeCommit,
180
177
  handleDomRotationCommit,
181
178
  handleDomManualEditsReset,
182
- handleDomMotionCommit,
183
- handleDomMotionClear,
184
179
  handleDomTextCommit,
185
180
  handleDomTextFieldStyleCommit,
186
181
  handleDomAddTextField,
@@ -0,0 +1,128 @@
1
+ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
2
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
+
4
+ export const PROPERTY_DEFAULTS: Record<string, number> = {
5
+ opacity: 1,
6
+ x: 0,
7
+ y: 0,
8
+ scale: 1,
9
+ scaleX: 1,
10
+ scaleY: 1,
11
+ rotation: 0,
12
+ width: 100,
13
+ height: 100,
14
+ };
15
+
16
+ export function ensureElementAddressable(selection: DomEditSelection): {
17
+ selector: string;
18
+ autoId?: string;
19
+ } {
20
+ if (selection.id) return { selector: `#${selection.id}` };
21
+ if (selection.selector) return { selector: selection.selector };
22
+
23
+ const el = selection.element;
24
+ const doc = el.ownerDocument;
25
+ const tag = el.tagName.toLowerCase();
26
+ let id = tag;
27
+ let n = 1;
28
+ while (doc.getElementById(id)) {
29
+ n += 1;
30
+ id = `${tag}-${n}`;
31
+ }
32
+ el.setAttribute("id", id);
33
+ return { selector: `#${id}`, autoId: id };
34
+ }
35
+
36
+ export class GsapMutationHttpError extends Error {
37
+ constructor(
38
+ readonly statusCode: number,
39
+ readonly responseBody: unknown,
40
+ ) {
41
+ super(formatGsapMutationHttpErrorMessage(statusCode, responseBody));
42
+ this.name = "GsapMutationHttpError";
43
+ }
44
+ }
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null;
48
+ }
49
+
50
+ export async function readJsonResponseBody(res: Response): Promise<unknown> {
51
+ const contentType = res.headers.get("content-type") ?? "";
52
+ if (!contentType.includes("application/json")) {
53
+ return await res.text().catch(() => null);
54
+ }
55
+ return await res.json().catch(() => null);
56
+ }
57
+
58
+ function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown): string {
59
+ if (isRecord(body) && typeof body.error === "string") {
60
+ return body.error;
61
+ }
62
+ return `GSAP mutation failed with status ${statusCode}`;
63
+ }
64
+
65
+ export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
66
+ const body = error.responseBody;
67
+ if (isRecord(body)) {
68
+ const fields = Array.isArray(body.fields)
69
+ ? body.fields.filter((field): field is string => typeof field === "string")
70
+ : [];
71
+ const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
72
+ return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
73
+ error.statusCode,
74
+ body,
75
+ )}${suffix}`;
76
+ }
77
+ return `Couldn't save animation: ${error.message}`;
78
+ }
79
+
80
+ interface AssignAutoIdParams {
81
+ projectId: string;
82
+ targetPath: string;
83
+ selection: DomEditSelection;
84
+ autoId: string;
85
+ showToast?: (message: string, tone?: "error" | "info") => void;
86
+ }
87
+
88
+ export async function assignGsapTargetAutoIdIfNeeded({
89
+ projectId,
90
+ targetPath,
91
+ selection,
92
+ autoId,
93
+ showToast,
94
+ }: AssignAutoIdParams): Promise<boolean> {
95
+ const patchBody = {
96
+ target: {
97
+ id: selection.id,
98
+ hfId: selection.hfId,
99
+ selector: selection.selector,
100
+ selectorIndex: selection.selectorIndex,
101
+ },
102
+ operations: [{ type: "html-attribute", property: "id", value: autoId }],
103
+ };
104
+ const unsafePatchFields = findUnsafeDomPatchValues(patchBody);
105
+ if (unsafePatchFields.length > 0) {
106
+ showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
107
+ return false;
108
+ }
109
+ const res = await fetch(
110
+ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
111
+ {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify(patchBody),
115
+ },
116
+ );
117
+ if (!res.ok) {
118
+ showToast?.(
119
+ formatGsapMutationRejectionToast(
120
+ new GsapMutationHttpError(res.status, await readJsonResponseBody(res)),
121
+ ),
122
+ "error",
123
+ );
124
+ return false;
125
+ }
126
+ const data = (await res.json()) as { changed?: boolean };
127
+ return data.changed === true;
128
+ }