@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useRef } from "react";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
@@ -10,6 +10,11 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
10
10
  import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
11
11
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
12
12
  import { useGsapCacheVersion } from "./useGsapTweenCache";
13
+ import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
14
+ import { useDomEditPersist } from "./useDomEditPersist";
15
+ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
16
+ import { useMountEffect } from "./useMountEffect";
17
+ import { stageElementOffset } from "./elementOffsetStager";
13
18
 
14
19
  /**
15
20
  * Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
@@ -77,6 +82,41 @@ export function useDomGeometryCommit({
77
82
  showToast,
78
83
  writeProjectFile,
79
84
  });
85
+ const [queue] = useState(createDomEditSaveQueue);
86
+ useMountEffect(() => () => queue.destroy());
87
+ const persistDomEditOperations = useDomEditPersist({
88
+ activeCompPath,
89
+ previewIframeRef: iframeRef,
90
+ showToast,
91
+ queueDomEditSave: queue.enqueue,
92
+ writeProjectFile,
93
+ editHistory,
94
+ projectIdRef,
95
+ reloadPreview: noop,
96
+ });
97
+ const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
98
+ activeCompPath,
99
+ persistDomEditOperations,
100
+ showToast,
101
+ });
102
+ // No paused-save banner in a host: each save is a retry, as in useDomStyleCommit.
103
+ const commitWithFreshQueue = useCallback<typeof commitPositionPatchToHtml>(
104
+ (...args) => {
105
+ queue.reset();
106
+ return commitPositionPatchToHtml(...args);
107
+ },
108
+ [commitPositionPatchToHtml, queue],
109
+ );
110
+ const stageElementPositionOffset = useCallback(
111
+ (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
112
+ stageElementOffset(
113
+ { commitPositionPatchToHtml: commitWithFreshQueue, showToast },
114
+ selection,
115
+ next,
116
+ coalesceKey,
117
+ ),
118
+ [commitWithFreshQueue, showToast],
119
+ );
80
120
  const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
81
121
  const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
82
122
  const {
@@ -94,6 +134,7 @@ export function useDomGeometryCommit({
94
134
  bumpGsapCache,
95
135
  makeFetchFallback,
96
136
  trackGsapInteractionFailure,
137
+ stageElementPositionOffset,
97
138
  handleDomBoxSizeCommit: noDomBoxSizeRoute,
98
139
  addGsapAnimation: gsap.addGsapAnimation,
99
140
  convertToKeyframes: gsap.convertToKeyframes,
@@ -143,3 +143,30 @@ describe("useDomGeometryCommits read-only preview", () => {
143
143
  unmount();
144
144
  });
145
145
  });
146
+
147
+ describe("useDomGeometryCommits element position offset", () => {
148
+ it("persists left/top on the element and no translate offset", async () => {
149
+ const element = document.createElement("span");
150
+ Object.defineProperties(element, {
151
+ offsetLeft: { get: () => 100 + (Number.parseFloat(element.style.left) || 0) },
152
+ offsetTop: { get: () => 200 + (Number.parseFloat(element.style.top) || 0) },
153
+ });
154
+ document.body.append(element);
155
+ const selection = { hfId: "w0", selector: ".w", element } as unknown as DomEditSelection;
156
+ const commitPositionPatchToHtml = vi
157
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
158
+ .mockResolvedValue(undefined);
159
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
160
+
161
+ await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }).save();
162
+
163
+ const patches = commitPositionPatchToHtml.mock.calls[0]![1];
164
+ expect(patches).toEqual([
165
+ { type: "inline-style", property: "position", value: "relative" },
166
+ { type: "inline-style", property: "left", value: "40px" },
167
+ { type: "inline-style", property: "top", value: "20px" },
168
+ ]);
169
+ expect(element.style.getPropertyValue("translate")).toBe("");
170
+ unmount();
171
+ });
172
+ });
@@ -14,6 +14,7 @@ import {
14
14
  clearStudioBoxSize,
15
15
  clearStudioRotation,
16
16
  } from "../components/editor/manualEdits";
17
+ import { stageElementOffset } from "./elementOffsetStager";
17
18
  import {
18
19
  buildPathOffsetPatches,
19
20
  buildBoxSizePatches,
@@ -47,7 +48,7 @@ export interface UseDomGeometryCommitsParams {
47
48
  commitPositionPatchToHtml: (
48
49
  selection: DomEditSelection,
49
50
  patches: PatchOperation[],
50
- options: { label: string; coalesceKey: string; skipRefresh?: boolean },
51
+ options: { label: string; coalesceKey: string; coalesceMs?: number; skipRefresh?: boolean },
51
52
  ) => Promise<void>;
52
53
  readOnlyPreview: boolean;
53
54
  }
@@ -58,6 +59,17 @@ export function useDomGeometryCommits({
58
59
  commitPositionPatchToHtml,
59
60
  readOnlyPreview,
60
61
  }: UseDomGeometryCommitsParams) {
62
+ const stageElementPositionOffset = useCallback(
63
+ (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
64
+ stageElementOffset(
65
+ { commitPositionPatchToHtml, showToast, readOnlyPreview },
66
+ selection,
67
+ next,
68
+ coalesceKey,
69
+ ),
70
+ [commitPositionPatchToHtml, readOnlyPreview, showToast],
71
+ );
72
+
61
73
  const handleDomPathOffsetCommit = useCallback(
62
74
  (selection: DomEditSelection, next: { x: number; y: number }) => {
63
75
  if (readOnlyPreview) return Promise.resolve();
@@ -163,6 +175,7 @@ export function useDomGeometryCommits({
163
175
  );
164
176
 
165
177
  return {
178
+ stageElementPositionOffset,
166
179
  handleDomPathOffsetCommit,
167
180
  handleDomBoxSizeCommit,
168
181
  handleDomRotationCommit,
@@ -52,6 +52,9 @@ function mountResizeHandler(
52
52
  const element = document.createElement("div");
53
53
  const selection = { element, id: "clip", selector: "#clip" } as unknown as DomEditSelection;
54
54
  const fallback = vi.fn().mockResolvedValue(undefined);
55
+ const anchorSave = vi.fn().mockResolvedValue(undefined);
56
+ const anchorRollback = vi.fn();
57
+ const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback }));
55
58
  const commitMutation = vi.fn().mockResolvedValue(undefined);
56
59
  let resize:
57
60
  | ((
@@ -71,6 +74,7 @@ function mountResizeHandler(
71
74
  bumpGsapCache: vi.fn(),
72
75
  makeFetchFallback: () => vi.fn().mockResolvedValue(targetAnimations),
73
76
  trackGsapInteractionFailure: vi.fn(),
77
+ stageElementPositionOffset: elementOffset,
74
78
  handleDomBoxSizeCommit: fallback,
75
79
  addGsapAnimation: vi.fn(),
76
80
  convertToKeyframes: vi.fn(),
@@ -80,7 +84,16 @@ function mountResizeHandler(
80
84
  return null;
81
85
  }
82
86
  const root = mountReactHarness(<Harness />);
83
- return { selection, fallback, commitMutation, resize: resize!, root };
87
+ return {
88
+ selection,
89
+ fallback,
90
+ elementOffset,
91
+ anchorSave,
92
+ anchorRollback,
93
+ commitMutation,
94
+ resize: resize!,
95
+ root,
96
+ };
84
97
  }
85
98
 
86
99
  type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
@@ -89,11 +102,16 @@ function mountGroupHandler({
89
102
  gsapCommitMutation,
90
103
  makeFetchFallback,
91
104
  trackGsapInteractionFailure = vi.fn(),
105
+ stageElementPositionOffset = vi.fn(),
92
106
  }: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
93
- Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure">>) {
107
+ Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure" | "stageElementPositionOffset">>) {
94
108
  let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
109
+ let pathOffsetCommit!: (
110
+ selection: DomEditSelection,
111
+ next: { x: number; y: number },
112
+ ) => Promise<void>;
95
113
  function Harness() {
96
- groupCommit = useGsapAwareEditing({
114
+ const editing = useGsapAwareEditing({
97
115
  domEditSelection: null,
98
116
  selectedGsapAnimations: [],
99
117
  gsapCommitMutation,
@@ -102,18 +120,75 @@ function mountGroupHandler({
102
120
  bumpGsapCache: vi.fn(),
103
121
  makeFetchFallback,
104
122
  trackGsapInteractionFailure,
123
+ stageElementPositionOffset,
105
124
  handleDomBoxSizeCommit: vi.fn(),
106
125
  addGsapAnimation: vi.fn(),
107
126
  convertToKeyframes: vi.fn(),
108
127
  setArcPath: vi.fn(),
109
128
  updateArcSegment: vi.fn(),
110
- }).handleGsapAwareGroupPathOffsetCommit;
129
+ });
130
+ groupCommit = editing.handleGsapAwareGroupPathOffsetCommit;
131
+ pathOffsetCommit = editing.handleGsapAwarePathOffsetCommit;
111
132
  return null;
112
133
  }
113
134
  const root = mountReactHarness(<Harness />);
114
- return { groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates), root };
135
+ return {
136
+ groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates),
137
+ pathOffsetCommit: (selection: DomEditSelection, next: { x: number; y: number }) =>
138
+ pathOffsetCommit(selection, next),
139
+ root,
140
+ };
115
141
  }
116
142
 
143
+ describe("useGsapAwareEditing shared-tween moves", () => {
144
+ it("saves a single drag through the element's own offset", async () => {
145
+ mocks.drag.mockResolvedValue({ status: "element-offset" });
146
+ const save = vi.fn().mockResolvedValue(undefined);
147
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
148
+ const commitMutation = vi.fn();
149
+ const { pathOffsetCommit, root } = mountGroupHandler({
150
+ gsapCommitMutation: commitMutation,
151
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
152
+ stageElementPositionOffset,
153
+ });
154
+ const word = { element: document.createElement("span"), hfId: "w0", selector: ".w" };
155
+ await act(() => pathOffsetCommit(word as unknown as DomEditSelection, { x: 40, y: 20 }));
156
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 });
157
+ expect(save).toHaveBeenCalledTimes(1);
158
+ expect(commitMutation).not.toHaveBeenCalled();
159
+ act(() => root.unmount());
160
+ });
161
+
162
+ it("saves a group member that shares a tween through its own offset, under the group key", async () => {
163
+ mocks.drag.mockImplementation(async (selection, _next, _a, _i, commit, _f, options) => {
164
+ if (selection.hfId === "w0") return { status: "element-offset" };
165
+ if (!options?.preflightOnly) await commit(selection, { type: "move" }, { label: "Move" });
166
+ return { status: "persisted" };
167
+ });
168
+ const save = vi.fn().mockResolvedValue(undefined);
169
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
170
+ const { groupCommit, root } = mountGroupHandler({
171
+ gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
172
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
173
+ stageElementPositionOffset,
174
+ });
175
+ const word = { element: document.createElement("span"), hfId: "w0", selector: ".w" };
176
+ const box = { element: document.createElement("div"), id: "box", selector: "#box" };
177
+ await act(() =>
178
+ groupCommit([
179
+ { selection: word, next: { x: 40, y: 20 } },
180
+ { selection: box, next: { x: 40, y: 20 } },
181
+ ] as unknown as DomEditGroupPathOffsetCommit[]),
182
+ );
183
+ expect(stageElementPositionOffset).toHaveBeenCalledWith(
184
+ word,
185
+ { x: 40, y: 20 },
186
+ expect.stringMatching(/^group-drag:\d+$/),
187
+ );
188
+ act(() => root.unmount());
189
+ });
190
+ });
191
+
117
192
  describe("useGsapAwareEditing anchored resize", () => {
118
193
  it("uses the explicit target's animations instead of the human selection cache", async () => {
119
194
  const humanAnimation = { id: "human", propertyGroup: "scale" } as GsapAnimation;
@@ -161,6 +236,39 @@ describe("useGsapAwareEditing anchored resize", () => {
161
236
  act(() => h.root.unmount());
162
237
  });
163
238
 
239
+ function resizeWritesSize() {
240
+ mocks.resize.mockImplementation(async (selection, _next, _a, _i, commit) => {
241
+ await commit(selection, { type: "size" }, { label: "Resize", softReload: true });
242
+ return { status: "persisted" };
243
+ });
244
+ mocks.drag.mockResolvedValue({ status: "element-offset" });
245
+ }
246
+
247
+ it("saves a shared-tween word's anchor move after its size, under the resize's undo key", async () => {
248
+ resizeWritesSize();
249
+ const h = mountResizeHandler([]);
250
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
251
+ const key = h.commitMutation.mock.calls[0]![2].coalesceKey;
252
+ expect(key).toMatch(/^tx:/);
253
+ expect(h.elementOffset).toHaveBeenCalledWith(h.selection, { x: -50, y: -25 }, key);
254
+ expect(h.anchorSave.mock.invocationCallOrder[0]).toBeGreaterThan(
255
+ h.commitMutation.mock.invocationCallOrder[0]!,
256
+ );
257
+ act(() => h.root.unmount());
258
+ });
259
+
260
+ it("does not keep the anchor move when the size save fails", async () => {
261
+ resizeWritesSize();
262
+ const h = mountResizeHandler([]);
263
+ h.commitMutation.mockRejectedValueOnce(new Error("size save failed"));
264
+ await expect(
265
+ act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 })),
266
+ ).rejects.toThrow("size save failed");
267
+ expect(h.anchorSave).not.toHaveBeenCalled();
268
+ expect(h.anchorRollback).toHaveBeenCalledTimes(1);
269
+ act(() => h.root.unmount());
270
+ });
271
+
164
272
  it("settles the live GSAP position before resize persistence reaches its first await", async () => {
165
273
  let resolveResize!: (outcome: { status: "persisted" }) => void;
166
274
  const pendingResize = new Promise<{ status: "persisted" }>((resolve) => {
@@ -34,9 +34,15 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
34
34
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
35
35
  import { runGestureTransaction } from "./gestureTransaction";
36
36
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
37
- import { assertGsapEditPersisted } from "./gsapEditOutcome";
37
+ import { assertGsapEditPersisted, type GsapEditOutcome } from "./gsapEditOutcome";
38
38
  import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
39
39
 
40
+ /** A move only a shared tween positions is saved on the element itself; a blocked one throws. */
41
+ async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise<void>) {
42
+ if (outcome.status === "element-offset") return saveOnElement();
43
+ assertGsapEditPersisted(outcome);
44
+ }
45
+
40
46
  // Distinct coalesceKey per group drag so consecutive group drags don't fold
41
47
  // into one another's undo entry (module-local counter, not Date.now()).
42
48
  let groupDragCommitCounter = 0;
@@ -71,6 +77,11 @@ export interface UseGsapAwareEditingParams {
71
77
  label: string,
72
78
  ) => void;
73
79
  // DOM fallbacks (from useDomEditCommits)
80
+ stageElementPositionOffset: (
81
+ selection: DomEditSelection,
82
+ next: { x: number; y: number },
83
+ coalesceKey?: string,
84
+ ) => { save: () => Promise<void>; rollback: () => void };
74
85
  handleDomBoxSizeCommit: (
75
86
  selection: DomEditSelection,
76
87
  next: { width: number; height: number },
@@ -117,6 +128,7 @@ export function useGsapAwareEditing({
117
128
  bumpGsapCache,
118
129
  makeFetchFallback,
119
130
  trackGsapInteractionFailure,
131
+ stageElementPositionOffset,
120
132
  handleDomBoxSizeCommit,
121
133
  addGsapAnimation,
122
134
  convertToKeyframes,
@@ -154,7 +166,7 @@ export function useGsapAwareEditing({
154
166
  makeFetchFallback(selection),
155
167
  modifiers,
156
168
  );
157
- assertGsapEditPersisted(outcome);
169
+ await saveMove(outcome, () => stageElementPositionOffset(selection, next).save());
158
170
  } catch (error) {
159
171
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
160
172
  throw error;
@@ -167,6 +179,7 @@ export function useGsapAwareEditing({
167
179
  makeFetchFallback,
168
180
  trackGsapInteractionFailure,
169
181
  getGsapAnimationsForSelection,
182
+ stageElementPositionOffset,
170
183
  ],
171
184
  );
172
185
 
@@ -222,6 +235,7 @@ export function useGsapAwareEditing({
222
235
  return Promise.resolve();
223
236
  };
224
237
  const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
238
+ const offsetMembers = new Set<DomEditSelection>();
225
239
  // Editability is user-atomic: prove every member can be written before
226
240
  // the first source mutation. Network failures after this point retain the
227
241
  // existing multi-request semantics, but a blocked member can never leave
@@ -242,6 +256,7 @@ export function useGsapAwareEditing({
242
256
  undefined,
243
257
  { preflightOnly: true, group: true },
244
258
  );
259
+ if (outcome.status === "element-offset") offsetMembers.add(selection);
245
260
  assertGsapEditPersisted(outcome);
246
261
  }),
247
262
  );
@@ -255,8 +270,15 @@ export function useGsapAwareEditing({
255
270
  );
256
271
  throw preflightFailure.error;
257
272
  }
273
+ const lastScriptWrite = updates.findLastIndex(
274
+ ({ selection }) => !offsetMembers.has(selection),
275
+ );
258
276
  for (const [index, { selection, next }] of updates.entries()) {
259
- renderOnCommit = index === updates.length - 1;
277
+ renderOnCommit = index === lastScriptWrite;
278
+ if (offsetMembers.has(selection)) {
279
+ await stageElementPositionOffset(selection, next, coalesceKey).save();
280
+ continue;
281
+ }
260
282
  try {
261
283
  const outcome = await tryGsapDragIntercept(
262
284
  selection,
@@ -288,7 +310,13 @@ export function useGsapAwareEditing({
288
310
  throw error;
289
311
  }
290
312
  },
291
- [gsapCommitMutation, previewIframeRef, makeFetchFallback, trackGsapInteractionFailure],
313
+ [
314
+ gsapCommitMutation,
315
+ previewIframeRef,
316
+ makeFetchFallback,
317
+ trackGsapInteractionFailure,
318
+ stageElementPositionOffset,
319
+ ],
292
320
  );
293
321
 
294
322
  const handleGsapAwareBoxSizeCommit = useCallback(
@@ -324,6 +352,7 @@ export function useGsapAwareEditing({
324
352
  animCount: targetAnimations.length,
325
353
  animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
326
354
  });
355
+ let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
327
356
  return runGestureTransaction({
328
357
  element: selection.element,
329
358
  label: "Resize layer",
@@ -339,7 +368,7 @@ export function useGsapAwareEditing({
339
368
  logResize("sync-settle", { gsapPos, offset, newX, newY });
340
369
  setElementGsapPosition(selection.element, newX, newY);
341
370
  },
342
- persist: async (commit) => {
371
+ persist: async (commit, coalesceKey) => {
343
372
  if (gsapCommitMutation) {
344
373
  const commitMutation = commit(gsapCommitMutation);
345
374
  try {
@@ -375,7 +404,10 @@ export function useGsapAwareEditing({
375
404
  commitMutation,
376
405
  makeFetchFallback(selection),
377
406
  );
378
- assertGsapEditPersisted(dragOutcome);
407
+ // Saved after the size, under its undo key, so the two are one step.
408
+ await saveMove(dragOutcome, async () => {
409
+ anchorMove = stageElementPositionOffset(selection, offset, coalesceKey);
410
+ });
379
411
  }
380
412
  logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
381
413
  return;
@@ -392,12 +424,17 @@ export function useGsapAwareEditing({
392
424
  logResizeSettle(selection.element, "dom-route");
393
425
  await handleDomBoxSizeCommit(selection, next, offset);
394
426
  },
395
- restore,
427
+ afterBufferedCommitsSaved: () => anchorMove?.save() ?? Promise.resolve(),
428
+ restore: () => {
429
+ anchorMove?.rollback();
430
+ restore();
431
+ },
396
432
  skipPixelAssert: hasLivePositionTween,
397
433
  });
398
434
  },
399
435
  [
400
436
  handleDomBoxSizeCommit,
437
+ stageElementPositionOffset,
401
438
  gsapCommitMutation,
402
439
  previewIframeRef,
403
440
  makeFetchFallback,
@@ -12,7 +12,8 @@ const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
12
12
  trackStudioEditBlocked: vi.fn(),
13
13
  trackStudioSaveFailure: vi.fn(),
14
14
  }));
15
- vi.mock("../utils/studioSaveDiagnostics", () => ({
15
+ vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
16
+ ...(await importOriginal<object>()),
16
17
  trackStudioEditBlocked,
17
18
  trackStudioSaveFailure,
18
19
  }));
@@ -2,6 +2,12 @@ import { useCallback } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
4
  import { isGsapEditBlockedError } from "./gsapEditOutcome";
5
+ import { wasAlreadyToasted } from "./domEditPersistFailure";
6
+
7
+ function failureToast(error: unknown): string | null {
8
+ if (wasAlreadyToasted(error)) return null;
9
+ return isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.";
10
+ }
5
11
 
6
12
  export function useGsapInteractionFailureTelemetry(
7
13
  activeCompPath: string | null,
@@ -22,10 +28,8 @@ export function useGsapInteractionFailureTelemetry(
22
28
  targetSelector: selection?.selector,
23
29
  targetSourceFile: selection?.sourceFile,
24
30
  });
25
- showToast(
26
- isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
27
- "error",
28
- );
31
+ const message = failureToast(error);
32
+ if (message) showToast(message, "error");
29
33
  },
30
34
  [activeCompPath, showToast],
31
35
  );
@@ -25,7 +25,7 @@ const rootElement: TimelineElement = {
25
25
  };
26
26
 
27
27
  // An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
28
- // host's master start), `sourceFile` is the sub-comp, and `expandedParentStart`
28
+ // host's master start), `sourceFile` is the sub-comp, and `parentCompositionStart`
29
29
  // is that host master start. Its authored time in the file is start - basis.
30
30
  const expandedChild: TimelineElement = {
31
31
  id: "child-clip",
@@ -35,7 +35,7 @@ const expandedChild: TimelineElement = {
35
35
  track: 1,
36
36
  domId: "child-clip",
37
37
  sourceFile: SUBCOMP_FILE,
38
- expandedParentStart: 2,
38
+ parentCompositionStart: 2,
39
39
  };
40
40
 
41
41
  interface SplitRequest {
@@ -119,13 +119,13 @@ describe("useRazorSplit — sub-comp coordinate rebasing", () => {
119
119
  expect(harness.splitRequests).toHaveLength(1);
120
120
  const req = harness.splitRequests[0];
121
121
  expect(req.path).toBe(SUBCOMP_FILE);
122
- expect(req.splitTime).toBe(3); // 5 - expandedParentStart(2), NOT 5
122
+ expect(req.splitTime).toBe(3); // 5 - parentCompositionStart(2), NOT 5
123
123
  expect(req.elementStart).toBe(0); // 2 - 2, NOT the master start 2
124
124
  expect(req.elementDuration).toBe(6);
125
125
  });
126
126
 
127
127
  it("leaves a root-level clip's coordinates unchanged", async () => {
128
- // Master split time T = 4; no expandedParentStart, so nothing is rebased.
128
+ // Master split time T = 4; no parentCompositionStart, so nothing is rebased.
129
129
  await act(async () => {
130
130
  await harness.singleRef.current!(rootElement, 4);
131
131
  });
@@ -154,7 +154,7 @@ describe("useRazorSplit — sub-comp coordinate rebasing", () => {
154
154
  expect(rootReq.splitTime).toBe(3);
155
155
  expect(rootReq.elementStart).toBe(0);
156
156
 
157
- // Expanded child: rebased by its OWN expandedParentStart, not the root's.
157
+ // Expanded child: rebased by its OWN parentCompositionStart, not the root's.
158
158
  expect(childReq.splitTime).toBe(1); // 3 - 2
159
159
  expect(childReq.elementStart).toBe(0); // 2 - 2
160
160
  });