@hyperframes/studio 0.8.85 → 0.8.87

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 (95) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
  7. package/dist/chunk-7PLTC6SV.js.map +1 -0
  8. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  9. package/dist/chunk-PQFEGZWM.js.map +1 -0
  10. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  11. package/dist/index.d.ts +207 -161
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2084 -1705
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +1 -1
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.test.tsx +53 -2
  19. package/src/components/TimelineToolbar.tsx +119 -101
  20. package/src/components/editor/DomEditOverlay.tsx +1 -2
  21. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  23. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  24. package/src/components/editor/LayersPanel.test.ts +14 -0
  25. package/src/components/editor/LayersPanel.tsx +2 -2
  26. package/src/components/editor/TopologyLens.tsx +2 -2
  27. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  28. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  29. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  30. package/src/components/editor/domEditing.test.ts +29 -0
  31. package/src/components/editor/domEditingDom.ts +1 -0
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  34. package/src/components/editor/marqueeCommit.ts +13 -1
  35. package/src/components/editor/useColorGradingController.test.ts +24 -0
  36. package/src/components/editor/useColorGradingController.ts +3 -2
  37. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  38. package/src/components/nle/useCompositionStack.test.tsx +179 -0
  39. package/src/components/nle/useCompositionStack.ts +26 -20
  40. package/src/hooks/domStyleCommit.ts +129 -0
  41. package/src/hooks/gsapEditOutcome.ts +2 -2
  42. package/src/hooks/gsapElementMatch.ts +38 -0
  43. package/src/hooks/gsapResizeIntercept.ts +10 -31
  44. package/src/hooks/gsapResizePreflight.ts +56 -0
  45. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  46. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  47. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  48. package/src/hooks/useDomEditCommits.ts +17 -182
  49. package/src/hooks/useDomEditPersist.ts +225 -0
  50. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  51. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  52. package/src/hooks/useDomEditSession.test.tsx +51 -1
  53. package/src/hooks/useDomEditSession.ts +11 -2
  54. package/src/hooks/useDomEditTextCommits.ts +15 -99
  55. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  56. package/src/hooks/useDomStyleCommit.ts +74 -0
  57. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  59. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  60. package/src/hooks/useGsapAwareEditing.ts +1 -1
  61. package/src/hooks/useGsapTweenCache.ts +2 -48
  62. package/src/index.ts +10 -1
  63. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  64. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  65. package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
  66. package/src/player/components/Timeline.test.ts +32 -19
  67. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  68. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  69. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  70. package/src/player/components/TimelineGroupRow.tsx +4 -1
  71. package/src/player/components/TimelineLanes.tsx +5 -5
  72. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  73. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  74. package/src/player/components/TimelineTypes.ts +1 -0
  75. package/src/player/components/timelineLaneProps.ts +1 -0
  76. package/src/player/components/timelineLayout.ts +16 -23
  77. package/src/player/components/timelineMountTestEnv.ts +29 -0
  78. package/src/player/components/useTimelineGeometry.test.tsx +105 -0
  79. package/src/player/components/useTimelineGeometry.ts +18 -10
  80. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  81. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  82. package/src/player/components/useTimelinePlayhead.ts +1 -1
  83. package/src/player/components/useTimelineProviderState.tsx +6 -12
  84. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  85. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  86. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  87. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  88. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  89. package/src/player/sceneSwap.test.ts +95 -0
  90. package/src/player/sceneSwap.ts +53 -0
  91. package/src/player/store/liveTime.ts +6 -1
  92. package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
  93. package/dist/chunk-3IOCVWB6.js.map +0 -1
  94. package/dist/chunk-7VDQIGOU.js.map +0 -1
  95. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
@@ -0,0 +1,225 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
3
+ import { trackStudioEvent } from "../utils/studioTelemetry";
4
+ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
5
+ import type { PersistDomEditOperations } from "./domEditCommitTypes";
6
+ import type { PatchOperation } from "../utils/sourcePatcher";
7
+ import {
8
+ DomEditPersistUnsafeValueError,
9
+ DomEditPersistUnresolvableError,
10
+ warnDomEditPersistNoOp,
11
+ } from "./domEditPersistFailure";
12
+ import {
13
+ formatUnsafeFieldList,
14
+ postPatchElement,
15
+ writePreparedContent,
16
+ } from "./useDomEditCommitsHelpers";
17
+ import type { CutoverResult } from "../utils/sdkCutover";
18
+ import { reseekPreviewRuntime } from "./timelineTrackVisibility";
19
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
20
+ import { readProjectFileContent } from "../utils/studioFileHistory";
21
+
22
+ export interface RecordEditInput {
23
+ label: string;
24
+ coalesceKey?: string;
25
+ coalesceMs?: number;
26
+ files: Record<string, { before: string; after: string }>;
27
+ }
28
+
29
+ export interface UseDomEditPersistParams {
30
+ activeCompPath: string | null;
31
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
32
+ showToast: (message: string, tone?: "error" | "info") => void;
33
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
34
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
35
+ editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
36
+ projectIdRef: React.MutableRefObject<string | null>;
37
+ reloadPreview: () => void;
38
+ forceReloadSdkSession?: () => void;
39
+ onTrySdkPersist?: (
40
+ selection: DomEditSelection,
41
+ operations: PatchOperation[],
42
+ originalContent: string,
43
+ targetPath: string,
44
+ options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
45
+ ) => Promise<CutoverResult>;
46
+ }
47
+
48
+ /** Studio's one DOM-edit writer: patches the source file, records history, then reloads or reseeks the preview. */
49
+ export function useDomEditPersist({
50
+ activeCompPath,
51
+ previewIframeRef,
52
+ showToast,
53
+ queueDomEditSave,
54
+ writeProjectFile,
55
+ editHistory,
56
+ projectIdRef,
57
+ reloadPreview,
58
+ forceReloadSdkSession,
59
+ onTrySdkPersist,
60
+ }: UseDomEditPersistParams): PersistDomEditOperations {
61
+ const reportedUnresolvableRef = useRef(new Set<string>());
62
+
63
+ // fallow-ignore-next-line complexity
64
+ const performPersistDomEditOperations = useCallback(
65
+ // fallow-ignore-next-line complexity
66
+ async (
67
+ selection: DomEditSelection,
68
+ operations: PatchOperation[],
69
+ options: Parameters<PersistDomEditOperations>[2],
70
+ expectedProjectId: string,
71
+ ) => {
72
+ if (projectIdRef.current !== expectedProjectId) {
73
+ throw new Error("Active project changed before the edit could be saved");
74
+ }
75
+ const pid = expectedProjectId;
76
+ if (options?.shouldSave && !options.shouldSave()) return;
77
+
78
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
79
+ const completePersistence = <T>(result: T, changed: boolean): T => {
80
+ if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
81
+ return result;
82
+ };
83
+
84
+ const readTarget = async (): Promise<string | null> => {
85
+ const content = await readProjectFileContent(pid, targetPath);
86
+ if (projectIdRef.current !== expectedProjectId) {
87
+ throw new Error("Active project changed before the edit could be saved");
88
+ }
89
+ return options?.shouldSave && !options.shouldSave() ? null : content;
90
+ };
91
+
92
+ // Validate layout values BEFORE any persist path runs. The SDK cutover
93
+ // path (onTrySdkPersist) returns early on success, so leaving this check
94
+ // after it let invalid numeric values bypass the guard whenever the
95
+ // cutover flag was on.
96
+ const patchTarget = buildDomEditPatchTarget(selection);
97
+ const patchBody = { target: patchTarget, operations };
98
+ const unsafeFields = findUnsafeDomPatchValues(patchBody);
99
+ if (unsafeFields.length > 0) {
100
+ const fields = formatUnsafeFieldList(unsafeFields);
101
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
102
+ throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
103
+ alreadyToasted: true,
104
+ });
105
+ }
106
+
107
+ // Skip the SDK path when prepareContent is set (e.g. @font-face injection
108
+ // for a custom font): sdkCutoverPersist serializes only the patched DOM
109
+ // and would drop the injected content. Let the server path run prepareContent.
110
+ // The SDK joins the file queue itself and re-reads there; this read is only its fallback.
111
+ if (onTrySdkPersist && !options?.prepareContent) {
112
+ const originalContent = await readTarget();
113
+ if (originalContent === null) return;
114
+ const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
115
+ label: options?.label,
116
+ coalesceKey: options?.coalesceKey,
117
+ skipRefresh: options?.skipRefresh,
118
+ });
119
+ if (cutover.status === "failed") throw cutover.error;
120
+ if (cutover.status === "committed") {
121
+ // SDK handled it — its in-memory doc is already current, so do NOT
122
+ // forceReload (that would echo-reload the session we just wrote).
123
+ return completePersistence(
124
+ { sourceFile: targetPath, version: cutover.version, changed: true },
125
+ true,
126
+ );
127
+ }
128
+ }
129
+
130
+ const history = {
131
+ label: options?.label ?? "Edit layer",
132
+ coalesceKey: options?.coalesceKey,
133
+ coalesceMs: options?.coalesceMs,
134
+ };
135
+ const prepare = options?.prepareContent;
136
+ // Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
137
+ const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
138
+ const originalContent = await readTarget();
139
+ if (originalContent === null) return null;
140
+ const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
141
+ if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
142
+
143
+ const patchedContent =
144
+ typeof patchData.content === "string" ? patchData.content : originalContent;
145
+ const finalContent = prepare
146
+ ? await writePreparedContent(
147
+ targetPath,
148
+ patchedContent,
149
+ prepare,
150
+ writeProjectFile,
151
+ showToast,
152
+ )
153
+ : patchedContent;
154
+
155
+ await editHistory.recordEdit({
156
+ ...history,
157
+ files: { [targetPath]: { before: originalContent, after: finalContent } },
158
+ });
159
+ return { patchData, patchedContent, finalContent };
160
+ });
161
+ if (saved === null) return;
162
+ const { patchData, patchedContent, finalContent } = saved;
163
+
164
+ if (finalContent === null) {
165
+ if (patchData.matched === false) {
166
+ const targetKey = selection.selector ?? selection.id ?? "selection";
167
+ if (!reportedUnresolvableRef.current.has(targetKey)) {
168
+ reportedUnresolvableRef.current.add(targetKey);
169
+ trackStudioEvent("save_skipped_unresolvable", {
170
+ target_id: selection.id ?? undefined,
171
+ target_selector: selection.selector ?? undefined,
172
+ target_source_file: selection.sourceFile ?? undefined,
173
+ composition: activeCompPath ?? undefined,
174
+ });
175
+ }
176
+ throw new DomEditPersistUnresolvableError(targetPath);
177
+ }
178
+ warnDomEditPersistNoOp(selection, operations);
179
+ return completePersistence(
180
+ typeof patchData.path === "string" && typeof patchData.version === "string"
181
+ ? { sourceFile: patchData.path, version: patchData.version, changed: false }
182
+ : undefined,
183
+ false,
184
+ );
185
+ }
186
+ forceReloadSdkSession?.();
187
+
188
+ if (!options?.skipRefresh) {
189
+ reloadPreview();
190
+ }
191
+ return completePersistence(
192
+ finalContent === patchedContent &&
193
+ typeof patchData.path === "string" &&
194
+ typeof patchData.version === "string"
195
+ ? { sourceFile: patchData.path, version: patchData.version, changed: true }
196
+ : undefined,
197
+ true,
198
+ );
199
+ },
200
+ [
201
+ activeCompPath,
202
+ editHistory,
203
+ writeProjectFile,
204
+ projectIdRef,
205
+ reloadPreview,
206
+ showToast,
207
+ forceReloadSdkSession,
208
+ onTrySdkPersist,
209
+ previewIframeRef,
210
+ ],
211
+ );
212
+
213
+ const persistDomEditOperations: PersistDomEditOperations = useCallback(
214
+ (selection, operations, options) => {
215
+ const expectedProjectId = projectIdRef.current;
216
+ if (!expectedProjectId) return Promise.reject(new Error("No active project"));
217
+ return queueDomEditSave(() =>
218
+ performPersistDomEditOperations(selection, operations, options, expectedProjectId),
219
+ );
220
+ },
221
+ [performPersistDomEditOperations, projectIdRef, queueDomEditSave],
222
+ );
223
+
224
+ return persistDomEditOperations;
225
+ }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { createElement, act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
7
+ import { sceneSwapFor } from "../player/sceneSwap";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ describe("useDomEditPreviewSync", () => {
12
+ it("re-syncs the preview when edited scenes are swapped in, and not when the swap is refused", async () => {
13
+ const iframe = document.createElement("iframe");
14
+ let refuse = false;
15
+ Object.defineProperty(iframe, "contentWindow", {
16
+ value: {
17
+ __hfSwapScenes: async () => {
18
+ if (refuse) throw new Error("the film changed outside its scenes");
19
+ },
20
+ },
21
+ });
22
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(""));
23
+ const applyManualEdits = vi.fn(async () => {});
24
+ const refreshVersion = vi.fn();
25
+ function Harness() {
26
+ useDomEditPreviewSync({
27
+ previewIframe: iframe,
28
+ activeCompPath: null,
29
+ captionEditMode: false,
30
+ domEditSelectionRef: { current: null },
31
+ domEditGroupSelectionsRef: { current: [] },
32
+ domEditSelection: null,
33
+ refreshDomEditGroupSelectionsFromPreview: async () => {},
34
+ applyDomSelection: () => {},
35
+ buildDomSelectionFromTarget: async () => null,
36
+ refreshPreviewDocumentVersion: refreshVersion,
37
+ syncPreviewHotkeys: () => {},
38
+ applyStudioManualEditsToPreviewRef: { current: applyManualEdits },
39
+ });
40
+ return null;
41
+ }
42
+ const root = createRoot(document.createElement("div"));
43
+ act(() => root.render(createElement(Harness)));
44
+ applyManualEdits.mockClear();
45
+ refreshVersion.mockClear();
46
+
47
+ refuse = true;
48
+ await act(() => sceneSwapFor(iframe)!("/preview", () => true).catch(() => {}));
49
+ expect(refreshVersion).not.toHaveBeenCalled();
50
+
51
+ refuse = false;
52
+ await act(() => sceneSwapFor(iframe)!("/preview", () => true));
53
+ expect(applyManualEdits).toHaveBeenCalledWith(iframe);
54
+ expect(refreshVersion).toHaveBeenCalledTimes(1);
55
+ act(() => root.unmount());
56
+ fetchSpy.mockRestore();
57
+ });
58
+ });
@@ -9,6 +9,7 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
9
9
  import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
10
10
  import type { PatchTarget } from "../utils/sourcePatcher";
11
11
  import { logSelect } from "../utils/selectDebug";
12
+ import { onPreviewContentReplaced } from "../player/sceneSwap";
12
13
 
13
14
  interface UseDomEditPreviewSyncParams {
14
15
  previewIframe: HTMLIFrameElement | null;
@@ -113,10 +114,7 @@ export function useDomEditPreviewSync({
113
114
  refreshPreviewDocumentVersion();
114
115
  };
115
116
 
116
- previewIframe.addEventListener("load", handleLoad);
117
- return () => {
118
- previewIframe.removeEventListener("load", handleLoad);
119
- };
117
+ return onPreviewContentReplaced(previewIframe, handleLoad);
120
118
  }, [
121
119
  activeCompPath,
122
120
  applyDomSelection,
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
5
5
  import { shouldUseSdkCutover } from "../utils/sdkCutover";
6
6
  import type { PatchOperation } from "../utils/sourcePatcher";
7
7
  import type { Composition } from "@hyperframes/sdk";
8
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
9
10
  import type { TimelineElement } from "../player";
10
11
  import type { UseDomEditSessionParams } from "./useDomEditSession";
@@ -62,6 +63,8 @@ const domEditSelectionRef: { current: DomEditSelection | null } = { current: nul
62
63
  const domEditGroupSelectionsRef: { current: DomEditSelection[] } = { current: [] };
63
64
  const groupSelectionSpy = vi.fn();
64
65
  const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
66
+ const neverParsed = new Promise<{ animations: GsapAnimation[] }>(() => undefined);
67
+ const parsedFile = { current: neverParsed };
65
68
 
66
69
  function createSessionParams(
67
70
  overrides: Partial<UseDomEditSessionParams> = {},
@@ -166,7 +169,12 @@ vi.mock("./useAskAgentModal", () => ({
166
169
  vi.mock("./useStudioSelectionPublisher", () => ({
167
170
  useStudioSelectionPublisher: () => {},
168
171
  }));
169
- vi.mock("./useGsapTweenCache", () => ({
172
+ vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({
173
+ ...(await importOriginal<typeof import("./keyframeCacheAstLoad")>()),
174
+ fetchParsedAnimations: () => parsedFile.current,
175
+ }));
176
+ vi.mock("./useGsapTweenCache", async (importOriginal) => ({
177
+ ...(await importOriginal<typeof import("./useGsapTweenCache")>()),
170
178
  useGsapCacheVersion: () => ({ version: 0, bump: vi.fn() }),
171
179
  }));
172
180
  vi.mock("./useGsapScriptCommits", () => ({
@@ -413,6 +421,48 @@ describe("bulk segment ease commits", () => {
413
421
  });
414
422
  });
415
423
 
424
+ describe("the session hands out selections narrowed to what the GSAP commit accepts", () => {
425
+ it("closes the move of a helper-loop element once its preflight lands", async () => {
426
+ const { useDomEditSession } = await import("./useDomEditSession");
427
+ domEditSelectionRef.current = {
428
+ id: "hero",
429
+ selector: "#hero",
430
+ element: document.createElement("div"),
431
+ sourceFile: "index.html",
432
+ capabilities: {
433
+ canApplyManualOffset: true,
434
+ canApplyManualSize: true,
435
+ canApplyManualRotation: true,
436
+ },
437
+ } as unknown as DomEditSelection;
438
+ const loop = { targetSelector: "#hero", properties: { x: 100 }, provenance: { kind: "loop" } };
439
+ parsedFile.current = Promise.resolve({ animations: [loop as unknown as GsapAnimation] });
440
+ const seen: { selection?: DomEditSelection | null } = {};
441
+ function Probe() {
442
+ seen.selection = useDomEditSession(createSessionParams()).domEditSelection;
443
+ return null;
444
+ }
445
+ const root = createRoot(document.createElement("div"));
446
+ try {
447
+ act(() => root.render(<Probe />));
448
+ expect(seen.selection?.capabilities.canApplyManualOffset).toBe(false);
449
+ await act(async () => {
450
+ for (let tick = 0; tick < 10; tick++) await Promise.resolve();
451
+ });
452
+ expect(seen.selection?.capabilities).toMatchObject({
453
+ canApplyManualOffset: false,
454
+ canApplyManualSize: true,
455
+ canApplyManualRotation: true,
456
+ reasonIfDisabled: expect.stringContaining("helper or loop"),
457
+ });
458
+ } finally {
459
+ domEditSelectionRef.current = null;
460
+ parsedFile.current = neverParsed;
461
+ act(() => root.unmount());
462
+ }
463
+ });
464
+ });
465
+
416
466
  // ── Grouping refuses audio ───────────────────────────────────────────────────
417
467
  //
418
468
  // A layout group is a positioned wrapper: it takes the members' bounding box,
@@ -19,6 +19,7 @@ import { useDomEditWiring } from "./useDomEditWiring";
19
19
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
20
20
  import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
21
21
  import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
22
+ import { useCommitPreflightCapabilities } from "./useCommitPreflightCapabilities";
22
23
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
23
24
  import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
24
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
@@ -494,9 +495,17 @@ export function useDomEditSession({
494
495
  });
495
496
  const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
496
497
  useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
498
+ const committable = useCommitPreflightCapabilities({
499
+ projectId,
500
+ enabled: gsapCommitMutation !== null,
501
+ selection: domEditSelection,
502
+ groupSelections: domEditGroupSelections,
503
+ previewIframeRef,
504
+ version: gsapCacheVersion,
505
+ });
497
506
  return {
498
- domEditSelection,
499
- domEditGroupSelections,
507
+ domEditSelection: committable.selection,
508
+ domEditGroupSelections: committable.groupSelections,
500
509
  domEditHoverSelection,
501
510
  activeGroupElement,
502
511
  agentModalOpen,
@@ -1,10 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import type { PatchOperation } from "../utils/sourcePatcher";
3
- import {
4
- isImageBackgroundValue,
5
- isManualGeometryStyleProperty,
6
- normalizeDomEditStyleValue,
7
- } from "../utils/studioHelpers";
2
+ import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
8
3
  import {
9
4
  injectPreviewGoogleFont,
10
5
  injectPreviewImportedFont,
@@ -12,7 +7,6 @@ import {
12
7
  } from "../utils/studioFontHelpers";
13
8
  import {
14
9
  buildDomEditRichTextPatchOperation,
15
- buildDomEditStylePatchOperation,
16
10
  findElementForSelection,
17
11
  getDomEditTargetKey,
18
12
  isTextEditableSelection,
@@ -32,6 +26,7 @@ import {
32
26
  runReportedDomEditCommit,
33
27
  type DomEditCommitOutcome,
34
28
  } from "./domEditCommitRunner";
29
+ import { commitDomStyles } from "./domStyleCommit";
35
30
  import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
36
31
  import type { InlineTextEditCommit } from "./useInlineTextEdit";
37
32
 
@@ -70,24 +65,6 @@ function ownsCurrentPreviewElement(
70
65
  return element === selection.element && element.ownerDocument === document;
71
66
  }
72
67
 
73
- function buildDomStyleCommitOperations(
74
- property: string,
75
- value: string,
76
- isImageBackgroundCommit: boolean,
77
- ): PatchOperation[] {
78
- const operations: PatchOperation[] = [
79
- buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
80
- ];
81
- if (isImageBackgroundCommit) {
82
- operations.push(
83
- buildDomEditStylePatchOperation("background-position", "center"),
84
- buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
85
- buildDomEditStylePatchOperation("background-size", "contain"),
86
- );
87
- }
88
- return operations;
89
- }
90
-
91
68
  async function resyncDomTextSelectionFromPreview(
92
69
  doc: Document | null | undefined,
93
70
  selection: DomEditSelection,
@@ -138,81 +115,20 @@ export function useDomEditTextCommits({
138
115
  });
139
116
 
140
117
  const handleDomStyleCommitForSelection = useCallback(
141
- async (
142
- selection: DomEditSelection,
143
- property: string,
144
- value: string,
145
- ): Promise<DomEditCommitOutcome> => {
146
- if (isManualGeometryStyleProperty(property))
147
- return domEditCommitDeclined("geometry-property");
148
- if (!selection.capabilities.canEditStyles) {
149
- return domEditCommitDeclined("styles-not-editable");
150
- }
151
- const styleCommitKey = `${getDomEditTargetKey(selection)}:${property}`;
152
- const isLatestStyleCommit = bumpDomEditCommitMapVersion(
153
- domStyleCommitVersionRef.current,
154
- styleCommitKey,
155
- );
156
- const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
157
- const iframe = previewIframeRef.current;
158
- const doc = iframe?.contentDocument;
159
- const normalizedValue = normalizeDomEditStyleValue(property, value);
160
- const isImageBackgroundCommit =
161
- property === "background-image" && isImageBackgroundValue(value);
162
- let editedElement: HTMLElement | null = null;
163
- let previousInlineValue: string | null = null;
164
- const operations = buildDomStyleCommitOperations(property, value, isImageBackgroundCommit);
165
- // Inline-style commits never full-reload the preview (that blanks the iframe
166
- // until it re-renders): the live element was already mutated optimistically in
167
- // apply(). z-index is no exception — setting `element.style.zIndex` restacks the
168
- // element in-browser immediately, so a reload would only cost a black blink.
169
- const skipRefresh = true;
170
-
171
- return runReportedDomEditCommit({
172
- capture: () => {
173
- if (!doc) return;
174
- const el = findElementForSelection(doc, selection, activeCompPath);
175
- if (!el) return;
176
- editedElement = el;
177
- previousInlineValue = el.style.getPropertyValue(property);
178
- },
179
- apply: () => {
180
- if (!editedElement) return;
181
- editedElement.style.setProperty(property, normalizedValue);
182
- if (property === "font-family" && doc) {
183
- injectPreviewGoogleFont(doc, value);
184
- if (importedFont) injectPreviewImportedFont(doc, importedFont);
185
- }
186
- if (isImageBackgroundCommit) {
187
- editedElement.style.setProperty("background-position", "center");
188
- editedElement.style.setProperty("background-repeat", "no-repeat");
189
- editedElement.style.setProperty("background-size", "contain");
190
- }
191
- },
192
- persist: () =>
193
- persistDomEditOperations(selection, operations, {
194
- label: "Edit layer style",
195
- skipRefresh,
196
- prepareContent: importedFont
197
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
198
- : undefined,
199
- }),
200
- shouldRevert: () => isLatestStyleCommit(),
201
- revert: () => {
202
- if (!editedElement || previousInlineValue === null) return;
203
- // ponytail: background-image side-effect styles are not reverted here.
204
- if (previousInlineValue === "") {
205
- editedElement.style.removeProperty(property);
206
- } else {
207
- editedElement.style.setProperty(property, previousInlineValue);
208
- }
118
+ (selection: DomEditSelection, property: string, value: string): Promise<DomEditCommitOutcome> =>
119
+ commitDomStyles(
120
+ {
121
+ activeCompPath,
122
+ previewIframeRef,
123
+ persistDomEditOperations,
124
+ showToast,
125
+ versions: domStyleCommitVersionRef.current,
126
+ resolveImportedFontAsset,
127
+ resync: refreshDomEditSelectionFromPreview,
209
128
  },
210
- onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
211
- shouldResync: isLatestStyleCommit,
212
- resync: () => refreshDomEditSelectionFromPreview(selection),
213
- onFinally: isLatestStyleCommit.release,
214
- });
215
- },
129
+ selection,
130
+ { [property]: value },
131
+ ),
216
132
  [
217
133
  activeCompPath,
218
134
  persistDomEditOperations,