@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -24,21 +24,16 @@ import {
24
24
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
25
25
  import { roundTo3 } from "../utils/rounding";
26
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
+ import {
28
+ assertGsapEditPersisted,
29
+ directEditOutcomeForProperties,
30
+ GsapEditBlockedError,
31
+ } from "./gsapEditOutcome";
32
+ import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
27
33
 
28
34
  interface CommitAnimatedPropertyDeps {
29
35
  selectedGsapAnimations: GsapAnimation[];
30
- gsapCommitMutation:
31
- | ((
32
- selection: DomEditSelection,
33
- mutation: Record<string, unknown>,
34
- options: {
35
- label: string;
36
- coalesceKey?: string;
37
- softReload?: boolean;
38
- skipReload?: boolean;
39
- },
40
- ) => Promise<void>)
41
- | null;
36
+ gsapCommitMutation: CommitMutation | null;
42
37
  addGsapAnimation: (
43
38
  selection: DomEditSelection,
44
39
  method: "to" | "from" | "set" | "fromTo",
@@ -68,6 +63,8 @@ function pickBestAnimation(
68
63
  if (candidates.length === 0) return undefined;
69
64
  if (candidates.length === 1) return candidates[0];
70
65
  const currentTime = usePlayerStore.getState().currentTime;
66
+ // Intentional multi-signal ranking: group match, selector specificity, and playhead overlap.
67
+ // fallow-ignore-next-line complexity
71
68
  const scored = candidates.map((a) => {
72
69
  let score = 0;
73
70
  if (a.keyframes) score += 10;
@@ -107,7 +104,7 @@ async function maybeAutoKeyframeSet(
107
104
  );
108
105
  }
109
106
 
110
- type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
107
+ type Commit = CommitMutation;
111
108
 
112
109
  /** Undo-history label for a static-set commit, from the group it writes. */
113
110
  const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
@@ -137,6 +134,17 @@ async function commitSetProps(
137
134
  animations: GsapAnimation[],
138
135
  commit: Commit,
139
136
  ): Promise<void> {
137
+ const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
138
+ await commit(call.selection, call.mutation, call.options);
139
+ await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
140
+ }
141
+
142
+ function buildSetPropsCall(
143
+ selection: DomEditSelection,
144
+ setAnim: GsapAnimation,
145
+ propEntries: [string, number | string][],
146
+ selector: string | null,
147
+ ): CommitMutationCall {
140
148
  const properties = Object.fromEntries(propEntries);
141
149
  const numericProps: SetPatchProps = {};
142
150
  for (const [k, v] of propEntries) {
@@ -152,16 +160,15 @@ async function commitSetProps(
152
160
  },
153
161
  }
154
162
  : undefined;
155
- await commit(
163
+ return {
156
164
  selection,
157
- { type: "update-properties", animationId: setAnim.id, properties },
158
- {
165
+ mutation: { type: "update-properties", animationId: setAnim.id, properties },
166
+ options: {
159
167
  label: staticSetLabel(propEntries),
160
168
  softReload: true,
161
169
  ...(instantPatch ? { instantPatch } : {}),
162
170
  },
163
- );
164
- await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
171
+ };
165
172
  }
166
173
 
167
174
  /**
@@ -177,7 +184,25 @@ async function commitStaticSet(
177
184
  animations: GsapAnimation[],
178
185
  commit: Commit,
179
186
  ): Promise<void> {
180
- if (!selector) return;
187
+ const calls = planStaticSetCalls(selection, propEntries, selector, animations);
188
+ const only = calls[0];
189
+ if (!only) return;
190
+ if (calls.length === 1) {
191
+ await commit(only.selection, only.mutation, only.options);
192
+ return;
193
+ }
194
+ if (!commit.batch) {
195
+ throw new Error("Atomic GSAP property batch is unavailable");
196
+ }
197
+ await commit.batch(calls, {
198
+ label: staticSetLabel(propEntries),
199
+ softReload: true,
200
+ });
201
+ }
202
+
203
+ function groupStaticSetEntries(
204
+ propEntries: [string, number | string][],
205
+ ): Map<string, [string, number | string][]> {
181
206
  // One commit per PROPERTY GROUP, each into a static write that owns that group —
182
207
  // never a live tween, and never a foreign-group write (a width edit used to
183
208
  // merge into the element's position set, producing a mixed write the split
@@ -191,9 +216,22 @@ async function commitStaticSet(
191
216
  batch.push(entry);
192
217
  byGroup.set(group, batch);
193
218
  }
194
- const staticWrites = animations.filter(
195
- (a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
196
- );
219
+ return byGroup;
220
+ }
221
+
222
+ function planStaticSetCalls(
223
+ selection: DomEditSelection,
224
+ propEntries: [string, number | string][],
225
+ selector: string | null,
226
+ animations: GsapAnimation[],
227
+ ): CommitMutationCall[] {
228
+ const byGroup = groupStaticSetEntries(propEntries);
229
+ const staticWrites = selector
230
+ ? animations.filter(
231
+ (a) =>
232
+ isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
233
+ )
234
+ : [];
197
235
  // Resolve every group's target BEFORE committing anything, and coalesce
198
236
  // groups that land on the SAME write into one commit: the snapshot is captured
199
237
  // once, so if two groups resolved to one legacy mixed write, a first
@@ -209,13 +247,12 @@ async function commitStaticSet(
209
247
  newSetBatches.push(batch);
210
248
  }
211
249
  }
212
- for (const [targetWrite, batch] of byTargetWrite) {
213
- await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
214
- }
215
- // Fresh adds don't reshape existing sets, so their ids can't go stale.
216
- for (const batch of newSetBatches) {
217
- await addGlobalStaticSet(selection, batch, commit);
218
- }
250
+ return [
251
+ ...[...byTargetWrite].map(([targetWrite, batch]) =>
252
+ buildSetPropsCall(selection, targetWrite, batch, selector),
253
+ ),
254
+ ...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
255
+ ];
219
256
  }
220
257
 
221
258
  /**
@@ -241,11 +278,10 @@ function findGroupOwningStaticWrite(
241
278
  * the timeline (matches the manual-drag UX). The global-set instant patch applies
242
279
  * it straight to the element so the first edit shows with no soft-reload flash.
243
280
  */
244
- async function addGlobalStaticSet(
281
+ function buildGlobalStaticSetCall(
245
282
  selection: DomEditSelection,
246
283
  batch: [string, number | string][],
247
- commit: Commit,
248
- ): Promise<void> {
284
+ ): CommitMutationCall {
249
285
  const numericProps: SetPatchProps = {};
250
286
  for (const [k, v] of batch) {
251
287
  if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
@@ -254,10 +290,10 @@ async function addGlobalStaticSet(
254
290
  // selector is the bare class an id-less element yields, which would hold every
255
291
  // sibling. No one-element form means no write at all (see writeTargetSelector).
256
292
  const target = writeTargetSelector(selection);
257
- if (!target) return;
258
- await commit(
293
+ if (!target) throw new GsapEditBlockedError("no-selector");
294
+ return {
259
295
  selection,
260
- {
296
+ mutation: {
261
297
  type: "add",
262
298
  targetSelector: target,
263
299
  method: "set",
@@ -265,7 +301,7 @@ async function addGlobalStaticSet(
265
301
  properties: Object.fromEntries(batch),
266
302
  global: true,
267
303
  },
268
- {
304
+ options: {
269
305
  label: staticSetLabel(batch),
270
306
  softReload: true,
271
307
  ...(Object.keys(numericProps).length > 0
@@ -277,7 +313,7 @@ async function addGlobalStaticSet(
277
313
  }
278
314
  : {}),
279
315
  },
280
- );
316
+ };
281
317
  }
282
318
 
283
319
  /** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
@@ -393,11 +429,19 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
393
429
  const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;
394
430
 
395
431
  const commitAnimatedProperties = useCallback(
432
+ // This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
433
+ // fallow-ignore-next-line complexity
396
434
  async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
397
435
  if (!gsapCommitMutation) return;
398
436
  const propEntries = Object.entries(props);
399
437
  if (propEntries.length === 0) return;
400
438
  const primaryProp = propEntries[0]![0];
439
+ assertGsapEditPersisted(
440
+ directEditOutcomeForProperties(
441
+ selectedGsapAnimations,
442
+ new Set(propEntries.map(([property]) => property)),
443
+ ),
444
+ );
401
445
 
402
446
  const iframe = previewIframeRef.current;
403
447
  const selector = selectorFromSelection(selection);
@@ -407,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
407
451
  selector,
408
452
  primaryProp,
409
453
  );
454
+ if (!anim && !writeTargetSelector(selection)) {
455
+ throw new GsapEditBlockedError("no-selector");
456
+ }
410
457
  // Whether the element is animated at all. A 3D edit only creates/edits
411
458
  // keyframes when it IS — a static element (no keyframes on any of its tweens)
412
459
  // gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
@@ -461,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
461
508
  return;
462
509
  }
463
510
 
464
- // Existing static hold on a NON-animated element merge the props into the
465
- // same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
466
- if (anim && isInstantHold(anim)) {
467
- await commitSetProps(
511
+ // Static element (no keyframes anywhere)persist as a `tl.set`, never
512
+ // keyframes (incl. the no-animation case, which creates a fresh set).
513
+ // Route the complete property set through the group-aware planner even
514
+ // when pickBestAnimation found one existing set: a mixed X+width edit
515
+ // must update the position set AND create a size set atomically rather
516
+ // than contaminating the first set with a foreign property group.
517
+ if (!elementHasKeyframes) {
518
+ await commitStaticSet(
468
519
  selection,
469
- anim,
470
520
  propEntries,
471
521
  selector,
472
522
  selectedGsapAnimations,
@@ -475,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
475
525
  return;
476
526
  }
477
527
 
478
- // Static element (no keyframes anywhere) persist as a `tl.set`, never
479
- // keyframes (incl. the no-animation case, which creates a fresh set).
480
- if (!elementHasKeyframes) {
481
- await commitStaticSet(
528
+ // Existing static hold on an otherwise animated element merge the props
529
+ // into the same write, then auto-keyframe it against the sibling tween.
530
+ if (anim && isInstantHold(anim)) {
531
+ await commitSetProps(
482
532
  selection,
533
+ anim,
483
534
  propEntries,
484
535
  selector,
485
536
  selectedGsapAnimations,
@@ -498,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
498
549
  // one-element form the edit is dropped rather than written onto every
499
550
  // class sibling (see writeTargetSelector).
500
551
  const newTweenTarget = writeTargetSelector(selection);
501
- if (selector && newTweenTarget) {
552
+ if (newTweenTarget) {
502
553
  const template = selectedGsapAnimations.find((a) => !!a.keyframes);
503
554
  const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
504
555
  const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -528,9 +579,10 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
528
579
  );
529
580
  return;
530
581
  }
582
+ throw new GsapEditBlockedError("no-selector");
583
+ } catch (error) {
531
584
  bumpGsapCache();
532
- } catch {
533
- bumpGsapCache();
585
+ throw error;
534
586
  }
535
587
  },
536
588
  [selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],
@@ -0,0 +1,215 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef, useState } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { TextAreaField } from "../components/editor/propertyPanelSections";
7
+ import type { DomEditSelection } from "../components/editor/domEditing";
8
+ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
9
+ import { usePlayerStore } from "../player/store/playerStore";
10
+ import { useAppHotkeys } from "./useAppHotkeys";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const timelineDelete = vi.fn(async () => undefined);
15
+ const domDelete = vi.fn(async () => undefined);
16
+ const keyframeDelete = vi.fn();
17
+ const textCommits = vi.fn();
18
+ let root: Root | null = null;
19
+
20
+ function parentWithTextChild(): DomEditSelection {
21
+ const element = document.createElement("section");
22
+ element.id = "selected-card";
23
+ const child = document.createElement("span");
24
+ child.textContent = "Kicker";
25
+ element.append(child);
26
+ return {
27
+ element,
28
+ id: "selected-card",
29
+ selector: "#selected-card",
30
+ selectorIndex: 0,
31
+ label: "Selected card",
32
+ tagName: "section",
33
+ sourceFile: "index.html",
34
+ compositionPath: "index.html",
35
+ isCompositionHost: false,
36
+ isInsideLockedComposition: false,
37
+ boundingBox: { x: 0, y: 0, width: 320, height: 180 },
38
+ textContent: "Kicker",
39
+ dataAttributes: {},
40
+ inlineStyles: {},
41
+ computedStyles: {},
42
+ textFields: [
43
+ {
44
+ key: "child:0:span",
45
+ label: "Content",
46
+ value: "Kicker",
47
+ tagName: "span",
48
+ attributes: [],
49
+ inlineStyles: {},
50
+ computedStyles: {},
51
+ source: "child",
52
+ sourceChildIndex: 0,
53
+ },
54
+ ],
55
+ capabilities: {
56
+ canSelect: true,
57
+ canEditStyles: true,
58
+ canCrop: false,
59
+ canMove: true,
60
+ canResize: true,
61
+ canApplyManualOffset: true,
62
+ canApplyManualSize: true,
63
+ canApplyManualRotation: true,
64
+ },
65
+ };
66
+ }
67
+
68
+ function Harness() {
69
+ const [value, setValue] = useState("Kicker");
70
+ const [selectionRefreshed, setSelectionRefreshed] = useState(false);
71
+ const selectionRef = useRef<DomEditSelection | null>(parentWithTextChild());
72
+ const clearSelectionRef = useRef<() => void>(() => undefined);
73
+ const saveTimestampRef = useRef(0);
74
+ const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
75
+
76
+ useAppHotkeys({
77
+ handleTimelineElementDelete: timelineDelete,
78
+ handleTimelineElementSplit: vi.fn(async () => undefined),
79
+ handleDomEditElementDelete: domDelete,
80
+ domEditSelectionRef: selectionRef,
81
+ clearDomSelectionRef: clearSelectionRef,
82
+ editHistory: {
83
+ undo: vi.fn(async () => ({ ok: false })),
84
+ redo: vi.fn(async () => ({ ok: false })),
85
+ state: { undo: [], redo: [] },
86
+ },
87
+ readOptionalProjectFile: vi.fn(async () => ""),
88
+ readProjectFile: vi.fn(async () => ""),
89
+ writeProjectFile: vi.fn(async () => undefined),
90
+ domEditSaveTimestampRef: saveTimestampRef,
91
+ showToast: vi.fn(),
92
+ syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
93
+ waitForPendingDomEditSaves: vi.fn(async () => undefined),
94
+ leftSidebarRef,
95
+ handleCopy: vi.fn(() => false),
96
+ handlePaste: vi.fn(async () => undefined),
97
+ handleCut: vi.fn(async () => false),
98
+ onResetKeyframes: vi.fn(() => false),
99
+ onDeleteSelectedKeyframes: keyframeDelete,
100
+ });
101
+
102
+ return (
103
+ <>
104
+ <TextAreaField
105
+ label="Content"
106
+ value={value}
107
+ onCommit={(next) => {
108
+ textCommits(next);
109
+ setValue(next);
110
+ // Text persistence rebuilds the selected parent's text-field model
111
+ // from the preview. Model that refresh: it must not transfer keyboard
112
+ // ownership away from the still-mounted Content editor.
113
+ setSelectionRefreshed(true);
114
+ }}
115
+ />
116
+ <button type="button" data-testid="canvas" data-selection-refreshed={selectionRefreshed}>
117
+ Canvas
118
+ </button>
119
+ </>
120
+ );
121
+ }
122
+
123
+ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
124
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
125
+ if (!setter) throw new Error("expected native textarea value setter");
126
+ setter.call(textarea, value);
127
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
128
+ textarea.setSelectionRange(value.length, value.length);
129
+ }
130
+
131
+ function pressBackspace(target: HTMLElement): KeyboardEvent {
132
+ const event = new KeyboardEvent("keydown", {
133
+ key: "Backspace",
134
+ bubbles: true,
135
+ cancelable: true,
136
+ });
137
+ target.dispatchEvent(event);
138
+ return event;
139
+ }
140
+
141
+ beforeEach(() => {
142
+ vi.useFakeTimers();
143
+ timelineDelete.mockClear();
144
+ domDelete.mockClear();
145
+ keyframeDelete.mockClear();
146
+ textCommits.mockClear();
147
+ usePlayerStore.getState().reset();
148
+ usePlayerStore.getState().setElements([
149
+ {
150
+ id: "selected-card",
151
+ tag: "section",
152
+ start: 0,
153
+ duration: 10,
154
+ track: 0,
155
+ },
156
+ ]);
157
+ usePlayerStore.getState().setSelectedElementId("selected-card");
158
+ });
159
+
160
+ afterEach(() => {
161
+ if (root) act(() => root?.unmount());
162
+ root = null;
163
+ document.body.innerHTML = "";
164
+ usePlayerStore.getState().reset();
165
+ vi.useRealTimers();
166
+ });
167
+
168
+ describe("useAppHotkeys text-field ownership", () => {
169
+ it("keeps two Backspaces in a child text editor across its scheduled save", () => {
170
+ const host = document.createElement("div");
171
+ document.body.append(host);
172
+ root = createRoot(host);
173
+ act(() => root?.render(<Harness />));
174
+
175
+ const textarea = host.querySelector("textarea");
176
+ const canvas = host.querySelector<HTMLButtonElement>('[data-testid="canvas"]');
177
+ if (!textarea || !canvas) throw new Error("expected text editor and canvas target");
178
+
179
+ textarea.focus();
180
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
181
+
182
+ const first = pressBackspace(textarea);
183
+ act(() => setTextareaValue(textarea, "Kicke"));
184
+ act(() => vi.advanceTimersByTime(120));
185
+
186
+ expect(first.defaultPrevented).toBe(false);
187
+ expect(textCommits).toHaveBeenLastCalledWith("Kicke");
188
+ expect(canvas.dataset.selectionRefreshed).toBe("true");
189
+ expect(document.activeElement).toBe(textarea);
190
+ expect(textarea.selectionStart).toBe(5);
191
+ expect(textarea.selectionEnd).toBe(5);
192
+
193
+ const second = pressBackspace(textarea);
194
+ act(() => setTextareaValue(textarea, "Kick"));
195
+ act(() => vi.advanceTimersByTime(120));
196
+
197
+ expect(second.defaultPrevented).toBe(false);
198
+ expect(textarea.value).toBe("Kick");
199
+ expect(textCommits.mock.calls).toEqual([["Kicke"], ["Kick"]]);
200
+ expect(document.activeElement).toBe(textarea);
201
+ expect(textarea.selectionStart).toBe(4);
202
+ expect(textarea.selectionEnd).toBe(4);
203
+ expect(timelineDelete).not.toHaveBeenCalled();
204
+ expect(domDelete).not.toHaveBeenCalled();
205
+ expect(keyframeDelete).not.toHaveBeenCalled();
206
+
207
+ canvas.focus();
208
+ const canvasDelete = pressBackspace(canvas);
209
+
210
+ expect(canvasDelete.defaultPrevented).toBe(true);
211
+ expect(timelineDelete).toHaveBeenCalledTimes(1);
212
+ expect(domDelete).not.toHaveBeenCalled();
213
+ expect(keyframeDelete).not.toHaveBeenCalled();
214
+ });
215
+ });
@@ -57,9 +57,9 @@ const recordResolverParity = vi.fn<(...args: unknown[]) => Promise<void>>(async
57
57
  const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
58
58
  fn: undefined,
59
59
  };
60
-
61
60
  const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
62
61
  const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
62
+
63
63
  vi.mock("../utils/sdkResolverShadow", () => ({
64
64
  runResolverShadow: vi.fn(),
65
65
  recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
@@ -537,11 +537,11 @@ export function useDomEditSession({
537
537
  handleGsapRemoveAllKeyframes,
538
538
  handleResetSelectedElementKeyframes,
539
539
  handleUpdateKeyframeEase,
540
+ handleUpdateSegmentEase,
540
541
  handleSetAllKeyframeEases,
541
542
  commitAnimatedProperty,
542
543
  commitAnimatedProperties,
543
544
  handleSetArcPath,
544
- handleUpdateSegmentEase,
545
545
  handleUpdateArcSegment,
546
546
  handleUnroll,
547
547
  invalidateGsapCache: bumpGsapCache,
@@ -0,0 +1,61 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import {
8
+ applyStudioBoxSize,
9
+ applyStudioPathOffset,
10
+ applyStudioRotation,
11
+ readStudioBoxSize,
12
+ readStudioPathOffset,
13
+ readStudioRotation,
14
+ } from "../components/editor/manualEdits";
15
+ import { useDomGeometryCommits } from "./useDomGeometryCommits";
16
+
17
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
+
19
+ describe("useDomGeometryCommits rollback", () => {
20
+ it("restores every optimistic geometry mutation when persistence rejects", async () => {
21
+ const element = document.createElement("div");
22
+ element.id = "box";
23
+ document.body.append(element);
24
+ applyStudioPathOffset(element, { x: 10, y: 20 });
25
+ applyStudioBoxSize(element, { width: 100, height: 80 });
26
+ applyStudioRotation(element, { angle: 15 });
27
+ const selection = {
28
+ id: "box",
29
+ selector: "#box",
30
+ element,
31
+ } as unknown as DomEditSelection;
32
+ const failure = new Error("save failed");
33
+ const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
34
+ let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
35
+ const host = document.createElement("div");
36
+ const root = createRoot(host);
37
+
38
+ function Probe() {
39
+ commits = useDomGeometryCommits({
40
+ previewIframeRef: { current: null },
41
+ showToast: vi.fn(),
42
+ commitPositionPatchToHtml,
43
+ });
44
+ return null;
45
+ }
46
+
47
+ act(() => root.render(<Probe />));
48
+ await expect(commits!.handleDomPathOffsetCommit(selection, { x: 50, y: 60 })).rejects.toBe(
49
+ failure,
50
+ );
51
+ await expect(
52
+ commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
53
+ ).rejects.toBe(failure);
54
+ await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
55
+
56
+ expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
57
+ expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
58
+ expect(readStudioRotation(element)).toEqual({ angle: 15 });
59
+ act(() => root.unmount());
60
+ });
61
+ });
@@ -4,6 +4,12 @@ import {
4
4
  applyStudioPathOffset,
5
5
  applyStudioBoxSize,
6
6
  applyStudioRotation,
7
+ captureStudioPathOffset,
8
+ captureStudioBoxSize,
9
+ captureStudioRotation,
10
+ restoreStudioPathOffset,
11
+ restoreStudioBoxSize,
12
+ restoreStudioRotation,
7
13
  clearStudioPathOffset,
8
14
  clearStudioBoxSize,
9
15
  clearStudioRotation,
@@ -51,10 +57,14 @@ export function useDomGeometryCommits({
51
57
  showToast(error.message, "error");
52
58
  return Promise.reject(error);
53
59
  }
60
+ const before = captureStudioPathOffset(selection.element);
54
61
  applyStudioPathOffset(selection.element, next);
55
62
  return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
56
63
  label: "Move layer",
57
64
  coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
65
+ }).catch((error) => {
66
+ restoreStudioPathOffset(selection.element, before);
67
+ throw error;
58
68
  });
59
69
  },
60
70
  [commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -71,6 +81,8 @@ export function useDomGeometryCommits({
71
81
  showToast(error.message, "error");
72
82
  return Promise.reject(error);
73
83
  }
84
+ const beforeSize = captureStudioBoxSize(selection.element);
85
+ const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
74
86
  applyStudioBoxSize(selection.element, next);
75
87
  // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
76
88
  // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
@@ -86,6 +98,10 @@ export function useDomGeometryCommits({
86
98
  return commitPositionPatchToHtml(selection, patches, {
87
99
  label: "Resize layer box",
88
100
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
101
+ }).catch((error) => {
102
+ restoreStudioBoxSize(selection.element, beforeSize);
103
+ if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
104
+ throw error;
89
105
  });
90
106
  },
91
107
  [commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -98,10 +114,14 @@ export function useDomGeometryCommits({
98
114
  showToast(error.message, "error");
99
115
  return Promise.reject(error);
100
116
  }
117
+ const before = captureStudioRotation(selection.element);
101
118
  applyStudioRotation(selection.element, next);
102
119
  return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
103
120
  label: "Rotate layer",
104
121
  coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
122
+ }).catch((error) => {
123
+ restoreStudioRotation(selection.element, before);
124
+ throw error;
105
125
  });
106
126
  },
107
127
  [commitPositionPatchToHtml, previewIframeRef, showToast],