@hyperframes/studio 0.8.86 → 0.8.88

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 (96) hide show
  1. package/dist/assets/hyperframes-player-w3Bbv0kd.js +473 -0
  2. package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
  3. package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
  4. package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
  7. package/dist/chunk-KU4F7QZ3.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 +206 -160
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2121 -1702
  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/TimelineHistoryButtons.dom.test.tsx +14 -3
  19. package/src/components/TimelineToolPicker.tsx +90 -0
  20. package/src/components/TimelineToolbar.tsx +2 -27
  21. package/src/components/editor/DomEditOverlay.tsx +1 -2
  22. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  23. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  24. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  25. package/src/components/editor/LayersPanel.test.ts +14 -0
  26. package/src/components/editor/LayersPanel.tsx +2 -2
  27. package/src/components/editor/TopologyLens.tsx +2 -2
  28. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  29. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  30. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  31. package/src/components/editor/domEditing.test.ts +29 -0
  32. package/src/components/editor/domEditingDom.ts +1 -0
  33. package/src/components/editor/domEditingTypes.ts +1 -0
  34. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  35. package/src/components/editor/marqueeCommit.ts +13 -1
  36. package/src/components/editor/useColorGradingController.test.ts +24 -0
  37. package/src/components/editor/useColorGradingController.ts +3 -2
  38. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  39. package/src/components/nle/useCompositionStack.test.tsx +53 -0
  40. package/src/components/nle/useCompositionStack.ts +4 -8
  41. package/src/hooks/appHotkeysDispatch.ts +8 -0
  42. package/src/hooks/domStyleCommit.ts +129 -0
  43. package/src/hooks/gsapEditOutcome.ts +2 -2
  44. package/src/hooks/gsapElementMatch.ts +38 -0
  45. package/src/hooks/gsapResizeIntercept.ts +10 -31
  46. package/src/hooks/gsapResizePreflight.ts +56 -0
  47. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  48. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  49. package/src/hooks/useAppHotkeys.test.ts +58 -1
  50. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  51. package/src/hooks/useDomEditCommits.ts +17 -182
  52. package/src/hooks/useDomEditPersist.ts +225 -0
  53. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  54. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  55. package/src/hooks/useDomEditSession.test.tsx +51 -1
  56. package/src/hooks/useDomEditSession.ts +11 -2
  57. package/src/hooks/useDomEditTextCommits.ts +15 -99
  58. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  59. package/src/hooks/useDomStyleCommit.ts +74 -0
  60. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  61. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  62. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  63. package/src/hooks/useGsapAwareEditing.ts +1 -1
  64. package/src/hooks/useGsapTweenCache.ts +2 -48
  65. package/src/index.ts +10 -1
  66. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  67. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  68. package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
  69. package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
  70. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  71. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  72. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  73. package/src/player/components/TimelineGroupRow.tsx +4 -1
  74. package/src/player/components/TimelineLanes.tsx +5 -5
  75. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  77. package/src/player/components/TimelineTypes.ts +2 -0
  78. package/src/player/components/studioShortcuts.ts +2 -0
  79. package/src/player/components/timelineLaneProps.ts +1 -0
  80. package/src/player/components/timelineMountTestEnv.ts +29 -0
  81. package/src/player/components/useTimelineKeyframeData.ts +14 -0
  82. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  83. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  84. package/src/player/components/useTimelineProviderState.tsx +10 -14
  85. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  86. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  87. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  88. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  89. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  90. package/src/player/sceneSwap.test.ts +95 -0
  91. package/src/player/sceneSwap.ts +53 -0
  92. package/src/player/store/playerStore.ts +16 -0
  93. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
  94. package/dist/chunk-3IOCVWB6.js.map +0 -1
  95. package/dist/chunk-7PLTC6SV.js.map +0 -1
  96. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
@@ -33,6 +33,7 @@ import {
33
33
  readGsapPositionFromIframe,
34
34
  } from "./gsapPositionDetection";
35
35
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
36
+ import { getAnimationsForElement } from "./gsapElementMatch";
36
37
  import {
37
38
  animationWritesAnyProperty,
38
39
  directEditOutcomeForProperties,
@@ -133,17 +134,28 @@ export type { GsapDragCommitCallbacks };
133
134
  * Returns an explicit persisted/blocked outcome. Callers must reject blocked
134
135
  * outcomes so the gesture layer restores its runtime and overlay drafts.
135
136
  */
136
- // fallow-ignore-next-line complexity
137
137
  async function preflightGsapDragIntercept(
138
138
  selection: DomEditSelection,
139
139
  animations: GsapAnimation[],
140
140
  iframe: HTMLIFrameElement | null,
141
141
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
142
+ group?: boolean,
142
143
  ): Promise<GsapEditOutcome> {
144
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
145
+ return dragEditOutcome(selection, animations, iframe, fetchedAnimations, group);
146
+ }
147
+
148
+ /** The move commit's refusal rule, also run ahead of time to hide the move handles. */
149
+ // fallow-ignore-next-line complexity
150
+ export function dragEditOutcome(
151
+ selection: DomEditSelection,
152
+ animations: GsapAnimation[],
153
+ iframe: HTMLIFrameElement | null,
154
+ fetchedAnimations: GsapAnimation[] = [],
155
+ group = false,
156
+ ): GsapEditOutcome {
143
157
  const selector = selectorFromSelection(selection);
144
158
  if (!selector) return { status: "blocked", reason: "no-selector" };
145
-
146
- const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
147
159
  // The fallback API currently represents both a definitive empty parse and an
148
160
  // exhausted fetch failure as `[]`. Keep the selected cache in the preflight
149
161
  // set as well: ignoring it would let a transient fetch failure bypass helper /
@@ -152,7 +164,12 @@ async function preflightGsapDragIntercept(
152
164
  const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
153
165
  if (editability.status === "blocked") return editability;
154
166
  const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
155
- const posAnim = findGsapPositionAnimation(sourceAnimations, selector);
167
+ // In a group only a tween naming this member by id or by its own selector counts,
168
+ // so a class tween shared with id-selected members cannot take one member's write.
169
+ const positionSources = group
170
+ ? getAnimationsForElement(sourceAnimations, { id: selection.id ?? null, selector })
171
+ : sourceAnimations;
172
+ const posAnim = findGsapPositionAnimation(positionSources, selector);
156
173
  const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
157
174
 
158
175
  if (hasLivePosition && !posAnim) {
@@ -177,7 +194,12 @@ export async function tryGsapDragIntercept(
177
194
  iframe: HTMLIFrameElement | null,
178
195
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
179
196
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
180
- options?: { altKey?: boolean; preflightOnly?: boolean; preflightPassed?: boolean },
197
+ options?: {
198
+ altKey?: boolean;
199
+ preflightOnly?: boolean;
200
+ preflightPassed?: boolean;
201
+ group?: boolean;
202
+ },
181
203
  ): Promise<GsapEditOutcome> {
182
204
  if (!options?.preflightPassed) {
183
205
  const preflight = await preflightGsapDragIntercept(
@@ -185,6 +207,7 @@ export async function tryGsapDragIntercept(
185
207
  animations,
186
208
  iframe,
187
209
  fetchFallbackAnimations,
210
+ options?.group,
188
211
  );
189
212
  if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
190
213
  }
@@ -312,6 +335,41 @@ export { readGsapProperty, readAllAnimatedProperties };
312
335
 
313
336
  // ── Identity-prop synthesis ───────────────────────────────────────────────
314
337
 
338
+ /** The rotation commit's refusal rule, also run ahead of time to hide the rotate handle. */
339
+ export function preflightGsapRotationIntercept(
340
+ selection: DomEditSelection,
341
+ animations: GsapAnimation[],
342
+ iframe: HTMLIFrameElement | null,
343
+ fetchedAnimations: GsapAnimation[] = [],
344
+ ): GsapEditOutcome {
345
+ const liveSelector = selectorFromSelection(selection);
346
+ if (!(liveSelector ?? writeTargetSelector(selection))) {
347
+ return { status: "blocked", reason: "no-selector" };
348
+ }
349
+ const editability = directEditOutcomeForProperties(
350
+ [...animations, ...fetchedAnimations],
351
+ ROTATION_CHANNEL_SET,
352
+ );
353
+ if (editability.status === "blocked") return editability;
354
+ const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
355
+ const hasSourceTween = workingAnimations.some((a) =>
356
+ animationWritesAnyProperty(a, ROTATION_CHANNEL_SET),
357
+ );
358
+ if (
359
+ !hasSourceTween &&
360
+ liveSelector &&
361
+ hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
362
+ ) {
363
+ // Rotation twin of the position case above: live tween, no source match.
364
+ return {
365
+ status: "blocked",
366
+ reason: "source-uneditable",
367
+ detail: "live-rotation-no-source-tween",
368
+ };
369
+ }
370
+ return { status: "persisted" };
371
+ }
372
+
315
373
  export async function tryGsapRotationIntercept(
316
374
  selection: DomEditSelection,
317
375
  angle: number,
@@ -320,16 +378,11 @@ export async function tryGsapRotationIntercept(
320
378
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
321
379
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
322
380
  ): Promise<GsapEditOutcome> {
323
- const selector = selectorFromSelection(selection) ?? writeTargetSelector(selection);
324
- if (!selector) return { status: "blocked", reason: "no-selector" };
325
-
326
381
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
382
+ const outcome = preflightGsapRotationIntercept(selection, animations, iframe, fetchedAnimations);
383
+ if (outcome.status === "blocked") return outcome;
384
+ const selector = (selectorFromSelection(selection) ?? writeTargetSelector(selection))!;
327
385
  const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
328
- const editability = directEditOutcomeForProperties(
329
- [...animations, ...fetchedAnimations],
330
- ROTATION_CHANNEL_SET,
331
- );
332
- if (editability.status === "blocked") return editability;
333
386
  const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
334
387
  ? fetchFallbackAnimations
335
388
  : undefined;
@@ -354,19 +407,6 @@ export async function tryGsapRotationIntercept(
354
407
  workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
355
408
  }
356
409
 
357
- const liveSelector = selectorFromSelection(selection);
358
- const hasLiveRotationTween = liveSelector
359
- ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
360
- : false;
361
- if (!anim && hasLiveRotationTween) {
362
- // Rotation twin of the position case above: live tween, no source match.
363
- return {
364
- status: "blocked",
365
- reason: "source-uneditable",
366
- detail: "live-rotation-no-source-tween",
367
- };
368
- }
369
-
370
410
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
371
411
  // pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
372
412
  // gesture's live preview already gsap.set this value (single source of truth).
@@ -53,12 +53,15 @@ function hasAnimations(value: unknown): value is ParsedGsapAnimations {
53
53
  */
54
54
  const inFlightParses = new Map<string, Promise<ParsedGsapAnimations | null>>();
55
55
 
56
+ export const parseCacheKey = (projectId: string, sourceFile: string) =>
57
+ `${projectId}|${sourceFile}`;
58
+
56
59
  export function fetchParsedAnimations(
57
60
  projectId: string,
58
61
  sourceFile: string,
59
62
  options: { fresh?: boolean } = {},
60
63
  ): Promise<ParsedGsapAnimations | null> {
61
- const key = `${projectId}|${sourceFile}`;
64
+ const key = parseCacheKey(projectId, sourceFile);
62
65
  if (options.fresh) inFlightParses.delete(key);
63
66
  const inFlight = inFlightParses.get(key);
64
67
  if (inFlight) return inFlight;
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
4
- import { usePlayerStore } from "../player/store/playerStore";
4
+ import { liveTime, usePlayerStore } from "../player/store/playerStore";
5
5
  import type { DomEditSelection } from "../components/editor/domEditing";
6
6
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
7
7
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
@@ -60,6 +60,63 @@ afterEach(() => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("dispatchPlainKey — select leftward / rightward", () => {
64
+ const clips = [
65
+ { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
66
+ { ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
67
+ { ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
68
+ ];
69
+ beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
70
+
71
+ it("[ selects every clip starting before the playhead, on every track", () => {
72
+ const event = press("[");
73
+ dispatchPlainKey(event, "[", callbacks());
74
+ expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
75
+ expect(usePlayerStore.getState().selectedElementId).toBe("early");
76
+ expect(event.defaultPrevented).toBe(true);
77
+ });
78
+
79
+ it("uses the live playhead while playing, not the time stored at play start", () => {
80
+ usePlayerStore.setState({ isPlaying: true, currentTime: 0 });
81
+ liveTime.notify(8);
82
+ try {
83
+ dispatchPlainKey(press("["), "[", callbacks());
84
+ expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
85
+ "at",
86
+ "early",
87
+ "late",
88
+ ]);
89
+ } finally {
90
+ usePlayerStore.setState({ isPlaying: false });
91
+ liveTime.notify(0);
92
+ }
93
+ });
94
+
95
+ it("clears a clicked keyframe like any other selection change", () => {
96
+ usePlayerStore.setState({ activeKeyframePct: 50 });
97
+ dispatchPlainKey(press("]"), "]", callbacks());
98
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
99
+ });
100
+
101
+ it("] selects every clip starting at or after the playhead, on every track", () => {
102
+ dispatchPlainKey(press("]"), "]", callbacks());
103
+ const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
104
+ expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
105
+ expect(selectedElementId).toBe("at");
106
+ });
107
+
108
+ it("selects nothing when no clip is on that side", () => {
109
+ usePlayerStore.setState({
110
+ currentTime: 0,
111
+ selectedElementId: "late",
112
+ selectedElementIds: new Set(["late"]),
113
+ });
114
+ dispatchPlainKey(press("["), "[", callbacks());
115
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
116
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
117
+ });
118
+ });
119
+
63
120
  describe("dispatchPlainKey — Delete arbitration", () => {
64
121
  const selectBgm = () =>
65
122
  usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
@@ -0,0 +1,147 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBridge";
5
+ import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
6
+ import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
7
+ import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
8
+ import {
9
+ gsapSourceFileForSelection,
10
+ selectElementAnimationsOrRetry,
11
+ } from "./useGsapAnimationFetchFallback";
12
+
13
+ interface CommitPreflight {
14
+ offset: GsapEditOutcome;
15
+ size: GsapEditOutcome;
16
+ rotation: GsapEditOutcome;
17
+ }
18
+
19
+ function runCommitPreflights(
20
+ selection: DomEditSelection,
21
+ fileAnimations: GsapAnimation[],
22
+ iframe: HTMLIFrameElement | null,
23
+ group: boolean,
24
+ ): CommitPreflight {
25
+ const target = { id: selection.id ?? null, selector: selection.selector ?? null };
26
+ const matched = selectElementAnimationsOrRetry(
27
+ { animations: fileAnimations },
28
+ target,
29
+ selection.element,
30
+ );
31
+ // A file the server parsed with no tweens at all is a definitive answer here.
32
+ const animations = matched.kind === "resolved" ? matched.animations : [];
33
+ return {
34
+ offset: dragEditOutcome(selection, animations, iframe, [], group),
35
+ size: preflightGsapResizeIntercept(selection, animations, iframe),
36
+ rotation: preflightGsapRotationIntercept(selection, animations, iframe),
37
+ };
38
+ }
39
+
40
+ // Studio can hand a narrowed copy back as a new selection; narrowing starts from the resolved one.
41
+ const resolvedSelections = new WeakMap<DomEditSelection, DomEditSelection>();
42
+
43
+ const resolvedOf = (selection: DomEditSelection) => resolvedSelections.get(selection) ?? selection;
44
+
45
+ const MANUAL_FLAGS = [
46
+ ["canApplyManualOffset", "offset"],
47
+ ["canApplyManualSize", "size"],
48
+ ["canApplyManualRotation", "rotation"],
49
+ ] as const;
50
+
51
+ /** Null when the commit would go through; "" while the check is still running. */
52
+ function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null {
53
+ const outcome = preflight?.[check];
54
+ if (outcome?.status === "persisted") return null;
55
+ return outcome ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : "";
56
+ }
57
+
58
+ /** Closes each manual flag whose commit Studio would refuse, and says why. */
59
+ function narrowCapabilities(
60
+ selection: DomEditSelection,
61
+ preflight: CommitPreflight | null,
62
+ ): DomEditSelection {
63
+ const resolved = resolvedOf(selection);
64
+ const next = { ...resolved.capabilities };
65
+ const reasons: string[] = [];
66
+ for (const [flag, check] of MANUAL_FLAGS) {
67
+ const reason = refusal(preflight, check);
68
+ if (!next[flag] || reason === null) continue;
69
+ next[flag] = false;
70
+ reasons.push(reason);
71
+ }
72
+ if (reasons.length === 0) return resolved;
73
+ next.reasonIfDisabled = reasons.find(Boolean) || next.reasonIfDisabled;
74
+ if (!preflight) next.commitCheckPending = true;
75
+ const narrowed = { ...resolved, capabilities: next };
76
+ resolvedSelections.set(narrowed, resolved);
77
+ return narrowed;
78
+ }
79
+
80
+ interface FileParse {
81
+ version: number;
82
+ animations: GsapAnimation[] | null;
83
+ }
84
+
85
+ /**
86
+ * The one place selection capabilities learn what the GSAP commit would refuse,
87
+ * so chrome, nudge and group gates never offer an edit that snaps back.
88
+ */
89
+ export function useCommitPreflightCapabilities({
90
+ projectId,
91
+ enabled,
92
+ selection,
93
+ groupSelections,
94
+ previewIframeRef,
95
+ version,
96
+ }: {
97
+ projectId: string | null;
98
+ enabled: boolean;
99
+ selection: DomEditSelection | null;
100
+ groupSelections: DomEditSelection[];
101
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
102
+ version: number;
103
+ }) {
104
+ // One parse per project file and version; the last good parse answers while a newer one loads.
105
+ const parsesRef = useRef(new Map<string, FileParse>());
106
+ const [parseTick, setParseTick] = useState(0);
107
+
108
+ useEffect(() => {
109
+ if (!enabled || !projectId) return;
110
+ const targets = selection ? [selection, ...groupSelections] : groupSelections;
111
+ for (const file of new Set(targets.map(gsapSourceFileForSelection))) {
112
+ const key = parseCacheKey(projectId, file);
113
+ const known = parsesRef.current.get(key);
114
+ if (known?.version === version) continue;
115
+ parsesRef.current.set(key, { version, animations: known?.animations ?? null });
116
+ void fetchParsedAnimations(projectId, file).then((parsed) => {
117
+ if (parsesRef.current.get(key)?.version !== version) return;
118
+ if (parsed) {
119
+ parsesRef.current.set(key, { version, animations: parsed.animations });
120
+ setParseTick((tick) => tick + 1);
121
+ return;
122
+ }
123
+ // A failed read is not an answer: keep the last one, and the next selection asks again.
124
+ if (known?.animations) parsesRef.current.set(key, known);
125
+ else parsesRef.current.delete(key);
126
+ });
127
+ }
128
+ }, [enabled, projectId, selection, groupSelections, version]);
129
+
130
+ return useMemo(() => {
131
+ void parseTick;
132
+ if (!enabled || !projectId) return { selection, groupSelections };
133
+ const group = groupSelections.length > 1;
134
+ const narrow = (target: DomEditSelection) => {
135
+ const file = gsapSourceFileForSelection(target);
136
+ const animations = parsesRef.current.get(parseCacheKey(projectId, file))?.animations;
137
+ const preflight = animations
138
+ ? runCommitPreflights(resolvedOf(target), animations, previewIframeRef.current, group)
139
+ : null;
140
+ return narrowCapabilities(target, preflight);
141
+ };
142
+ return {
143
+ selection: selection && narrow(selection),
144
+ groupSelections: groupSelections.map(narrow),
145
+ };
146
+ }, [enabled, projectId, selection, groupSelections, parseTick, previewIframeRef]);
147
+ }
@@ -1,20 +1,15 @@
1
1
  import { buildProjectApiPath } from "../utils/projectRouting";
2
- import { useCallback, useRef } from "react";
2
+ import { useCallback } from "react";
3
3
  import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
4
4
  import { FONT_EXT } from "../utils/mediaTypes";
5
-
6
- import { trackStudioEvent } from "../utils/studioTelemetry";
7
5
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
8
6
  import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
9
- import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
7
+ import type { DomEditSelection } from "../components/editor/domEditing";
10
8
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
11
- import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
9
+ import type { CommitDomEditPatchBatches } from "./domEditCommitTypes";
12
10
  import type { PatchOperation } from "../utils/sourcePatcher";
13
- import {
14
- DomEditPersistUnsafeValueError,
15
- DomEditPersistUnresolvableError,
16
- warnDomEditPersistNoOp,
17
- } from "./domEditPersistFailure";
11
+ import { DomEditPersistUnsafeValueError } from "./domEditPersistFailure";
12
+ import { useDomEditPersist, type RecordEditInput } from "./useDomEditPersist";
18
13
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
19
14
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
20
15
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
@@ -24,19 +19,9 @@ import {
24
19
  batchesAreInlineStyleOnly,
25
20
  formatUnsafeFieldList,
26
21
  patchElementBatches,
27
- postPatchElement,
28
- writePreparedContent,
29
22
  } from "./useDomEditCommitsHelpers";
30
23
  import type { CutoverResult } from "../utils/sdkCutover";
31
- import { reseekPreviewRuntime } from "./timelineTrackVisibility";
32
24
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
33
- import { readProjectFileContent } from "../utils/studioFileHistory";
34
- interface RecordEditInput {
35
- label: string;
36
- coalesceKey?: string;
37
- coalesceMs?: number;
38
- files: Record<string, { before: string; after: string }>;
39
- }
40
25
 
41
26
  export interface UseDomEditCommitsParams {
42
27
  activeCompPath: string | null;
@@ -132,168 +117,18 @@ export function useDomEditCommits({
132
117
  [fileTree, projectId, importedFontAssetsRef],
133
118
  );
134
119
 
135
- const reportedUnresolvableRef = useRef(new Set<string>());
136
-
137
- // fallow-ignore-next-line complexity
138
- const performPersistDomEditOperations = useCallback(
139
- // fallow-ignore-next-line complexity
140
- async (
141
- selection: DomEditSelection,
142
- operations: PatchOperation[],
143
- options: Parameters<PersistDomEditOperations>[2],
144
- expectedProjectId: string,
145
- ) => {
146
- if (projectIdRef.current !== expectedProjectId) {
147
- throw new Error("Active project changed before the edit could be saved");
148
- }
149
- const pid = expectedProjectId;
150
- if (options?.shouldSave && !options.shouldSave()) return;
151
-
152
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
153
- const completePersistence = <T>(result: T, changed: boolean): T => {
154
- if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
155
- return result;
156
- };
157
-
158
- const readTarget = async (): Promise<string | null> => {
159
- const content = await readProjectFileContent(pid, targetPath);
160
- if (projectIdRef.current !== expectedProjectId) {
161
- throw new Error("Active project changed before the edit could be saved");
162
- }
163
- return options?.shouldSave && !options.shouldSave() ? null : content;
164
- };
165
-
166
- // Validate layout values BEFORE any persist path runs. The SDK cutover
167
- // path (onTrySdkPersist) returns early on success, so leaving this check
168
- // after it let invalid numeric values bypass the guard whenever the
169
- // cutover flag was on.
170
- const patchTarget = buildDomEditPatchTarget(selection);
171
- const patchBody = { target: patchTarget, operations };
172
- const unsafeFields = findUnsafeDomPatchValues(patchBody);
173
- if (unsafeFields.length > 0) {
174
- const fields = formatUnsafeFieldList(unsafeFields);
175
- showToast("Couldn't save edit because it contains invalid layout values", "error");
176
- throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
177
- alreadyToasted: true,
178
- });
179
- }
180
-
181
- // Skip the SDK path when prepareContent is set (e.g. @font-face injection
182
- // for a custom font): sdkCutoverPersist serializes only the patched DOM
183
- // and would drop the injected content. Let the server path run prepareContent.
184
- // The SDK joins the file queue itself and re-reads there; this read is only its fallback.
185
- if (onTrySdkPersist && !options?.prepareContent) {
186
- const originalContent = await readTarget();
187
- if (originalContent === null) return;
188
- const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
189
- label: options?.label,
190
- coalesceKey: options?.coalesceKey,
191
- skipRefresh: options?.skipRefresh,
192
- });
193
- if (cutover.status === "failed") throw cutover.error;
194
- if (cutover.status === "committed") {
195
- // SDK handled it — its in-memory doc is already current, so do NOT
196
- // forceReload (that would echo-reload the session we just wrote).
197
- return completePersistence(
198
- { sourceFile: targetPath, version: cutover.version, changed: true },
199
- true,
200
- );
201
- }
202
- }
203
-
204
- const history = {
205
- label: options?.label ?? "Edit layer",
206
- coalesceKey: options?.coalesceKey,
207
- coalesceMs: options?.coalesceMs,
208
- };
209
- const prepare = options?.prepareContent;
210
- // Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
211
- const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
212
- const originalContent = await readTarget();
213
- if (originalContent === null) return null;
214
- const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
215
- if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
216
-
217
- const patchedContent =
218
- typeof patchData.content === "string" ? patchData.content : originalContent;
219
- const finalContent = prepare
220
- ? await writePreparedContent(
221
- targetPath,
222
- patchedContent,
223
- prepare,
224
- writeProjectFile,
225
- showToast,
226
- )
227
- : patchedContent;
228
-
229
- await editHistory.recordEdit({
230
- ...history,
231
- files: { [targetPath]: { before: originalContent, after: finalContent } },
232
- });
233
- return { patchData, patchedContent, finalContent };
234
- });
235
- if (saved === null) return;
236
- const { patchData, patchedContent, finalContent } = saved;
237
-
238
- if (finalContent === null) {
239
- if (patchData.matched === false) {
240
- const targetKey = selection.selector ?? selection.id ?? "selection";
241
- if (!reportedUnresolvableRef.current.has(targetKey)) {
242
- reportedUnresolvableRef.current.add(targetKey);
243
- trackStudioEvent("save_skipped_unresolvable", {
244
- target_id: selection.id ?? undefined,
245
- target_selector: selection.selector ?? undefined,
246
- target_source_file: selection.sourceFile ?? undefined,
247
- composition: activeCompPath ?? undefined,
248
- });
249
- }
250
- throw new DomEditPersistUnresolvableError(targetPath);
251
- }
252
- warnDomEditPersistNoOp(selection, operations);
253
- return completePersistence(
254
- typeof patchData.path === "string" && typeof patchData.version === "string"
255
- ? { sourceFile: patchData.path, version: patchData.version, changed: false }
256
- : undefined,
257
- false,
258
- );
259
- }
260
- forceReloadSdkSession?.();
261
-
262
- if (!options?.skipRefresh) {
263
- reloadPreview();
264
- }
265
- return completePersistence(
266
- finalContent === patchedContent &&
267
- typeof patchData.path === "string" &&
268
- typeof patchData.version === "string"
269
- ? { sourceFile: patchData.path, version: patchData.version, changed: true }
270
- : undefined,
271
- true,
272
- );
273
- },
274
- [
275
- activeCompPath,
276
- editHistory,
277
- writeProjectFile,
278
- projectIdRef,
279
- reloadPreview,
280
- showToast,
281
- forceReloadSdkSession,
282
- onTrySdkPersist,
283
- previewIframeRef,
284
- ],
285
- );
286
-
287
- const persistDomEditOperations: PersistDomEditOperations = useCallback(
288
- (selection, operations, options) => {
289
- const expectedProjectId = projectIdRef.current;
290
- if (!expectedProjectId) return Promise.reject(new Error("No active project"));
291
- return queueDomEditSave(() =>
292
- performPersistDomEditOperations(selection, operations, options, expectedProjectId),
293
- );
294
- },
295
- [performPersistDomEditOperations, projectIdRef, queueDomEditSave],
296
- );
120
+ const persistDomEditOperations = useDomEditPersist({
121
+ activeCompPath,
122
+ previewIframeRef,
123
+ showToast,
124
+ queueDomEditSave,
125
+ writeProjectFile,
126
+ editHistory,
127
+ projectIdRef,
128
+ reloadPreview,
129
+ forceReloadSdkSession,
130
+ onTrySdkPersist,
131
+ });
297
132
 
298
133
  const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
299
134
  (batches, options) => {