@hyperframes/studio 0.8.90 → 0.8.91
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.
- package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-5SFeUXP8.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-V-0rfhe6.js} +168 -168
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +1 -1
- package/dist/index.js +1298 -1003
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +18 -8
- package/src/player/components/timelineClipDragCommit.ts +5 -0
- package/src/player/components/timelineClipDragPreview.test.ts +70 -2
- package/src/player/components/timelineClipDragPreview.ts +15 -12
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
- package/src/player/components/timelineMultiDragPreview.ts +8 -5
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +7 -2
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
})
|
|
129
|
+
});
|
|
130
|
+
groupCommit = editing.handleGsapAwareGroupPathOffsetCommit;
|
|
131
|
+
pathOffsetCommit = editing.handleGsapAwarePathOffsetCommit;
|
|
111
132
|
return null;
|
|
112
133
|
}
|
|
113
134
|
const root = mountReactHarness(<Harness />);
|
|
114
|
-
return {
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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 `
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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
|
|
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
|
});
|