@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.
- package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -90,6 +90,75 @@ describe("FlatRow", () => {
|
|
|
90
90
|
act(() => root.unmount());
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
it("restores its durable value when an async commit rejects", async () => {
|
|
94
|
+
let rejectCommit: ((error: Error) => void) | null = null;
|
|
95
|
+
const onCommit = vi.fn(
|
|
96
|
+
() =>
|
|
97
|
+
new Promise<void>((_resolve, reject) => {
|
|
98
|
+
rejectCommit = reject;
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const row = (value: string) => (
|
|
102
|
+
<FlatRow label="X" value={value} tier="explicitDefault" onCommit={onCommit} />
|
|
103
|
+
);
|
|
104
|
+
const { host, root } = renderInto(row("22px"));
|
|
105
|
+
const input = host.querySelector<HTMLInputElement>("input");
|
|
106
|
+
if (!input) throw new Error("expected an input");
|
|
107
|
+
act(() => {
|
|
108
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
109
|
+
window.HTMLInputElement.prototype,
|
|
110
|
+
"value",
|
|
111
|
+
)?.set;
|
|
112
|
+
nativeInputValueSetter?.call(input, "99px");
|
|
113
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
114
|
+
input.dispatchEvent(new Event("focusout", { bubbles: true }));
|
|
115
|
+
});
|
|
116
|
+
// The parent can echo the preview before persistence settles. That is not a
|
|
117
|
+
// durable acknowledgement and must not invalidate the pending rollback.
|
|
118
|
+
act(() => root.render(row("99px")));
|
|
119
|
+
await act(async () => {
|
|
120
|
+
rejectCommit?.(new Error("save failed"));
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(onCommit).toHaveBeenCalledWith("99px");
|
|
125
|
+
expect(input.value).toBe("22px");
|
|
126
|
+
act(() => root.unmount());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not let an older rejected commit overwrite a newer draft", async () => {
|
|
130
|
+
let rejectCommit: ((error: Error) => void) | null = null;
|
|
131
|
+
const onCommit = vi.fn(
|
|
132
|
+
() =>
|
|
133
|
+
new Promise<void>((_resolve, reject) => {
|
|
134
|
+
rejectCommit = reject;
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
const { host, root } = renderInto(
|
|
138
|
+
<FlatRow label="X" value="22px" tier="explicitDefault" onCommit={onCommit} />,
|
|
139
|
+
);
|
|
140
|
+
const input = host.querySelector<HTMLInputElement>("input");
|
|
141
|
+
if (!input) throw new Error("expected an input");
|
|
142
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
143
|
+
window.HTMLInputElement.prototype,
|
|
144
|
+
"value",
|
|
145
|
+
)?.set;
|
|
146
|
+
act(() => {
|
|
147
|
+
nativeInputValueSetter?.call(input, "99px");
|
|
148
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
149
|
+
input.dispatchEvent(new Event("focusout", { bubbles: true }));
|
|
150
|
+
nativeInputValueSetter?.call(input, "100px");
|
|
151
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
152
|
+
});
|
|
153
|
+
await act(async () => {
|
|
154
|
+
rejectCommit?.(new Error("old save failed"));
|
|
155
|
+
await Promise.resolve();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(input.value).toBe("100px");
|
|
159
|
+
act(() => root.unmount());
|
|
160
|
+
});
|
|
161
|
+
|
|
93
162
|
it("persists a rapid numeric arrow-key burst as one commit", () => {
|
|
94
163
|
vi.useFakeTimers();
|
|
95
164
|
const onCommit = vi.fn();
|
|
@@ -35,7 +35,7 @@ export function FlatRow({
|
|
|
35
35
|
/** Renders a trailing 10px caret-down, for select-backed rows. */
|
|
36
36
|
dropdown?: boolean;
|
|
37
37
|
onPreview?: (nextValue: string) => void;
|
|
38
|
-
onCommit: (nextValue: string) => void
|
|
38
|
+
onCommit: (nextValue: string) => void | Promise<void>;
|
|
39
39
|
onReset?: () => void;
|
|
40
40
|
}) {
|
|
41
41
|
const track = useTrackDesignInput();
|
|
@@ -59,7 +59,7 @@ export function FlatRow({
|
|
|
59
59
|
onPreview={onPreview}
|
|
60
60
|
onCommit={(nextValue) => {
|
|
61
61
|
track("metric", label);
|
|
62
|
-
onCommit(nextValue);
|
|
62
|
+
return onCommit(nextValue);
|
|
63
63
|
}}
|
|
64
64
|
/>
|
|
65
65
|
</span>
|
|
@@ -29,14 +29,14 @@ export function MetricField({
|
|
|
29
29
|
scrub?: boolean;
|
|
30
30
|
suffix?: string;
|
|
31
31
|
tooltip?: string;
|
|
32
|
-
onCommit: (nextValue: string) => void
|
|
32
|
+
onCommit: (nextValue: string) => void | Promise<void>;
|
|
33
33
|
}) {
|
|
34
34
|
const track = useTrackDesignInput();
|
|
35
35
|
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
|
|
36
36
|
const commit = useCallback(
|
|
37
37
|
(nextValue: string) => {
|
|
38
38
|
if (nextValue !== value) track("metric", label);
|
|
39
|
-
onCommit(nextValue);
|
|
39
|
+
return onCommit(nextValue);
|
|
40
40
|
},
|
|
41
41
|
[label, onCommit, track, value],
|
|
42
42
|
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
5
|
+
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
|
|
6
|
+
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
|
7
|
+
|
|
8
|
+
describe("createTransformCommitHandlers", () => {
|
|
9
|
+
it.each([
|
|
10
|
+
[
|
|
11
|
+
"position",
|
|
12
|
+
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
|
|
13
|
+
handlers.commitManualOffset("x", "20px"),
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
"size",
|
|
17
|
+
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
|
|
18
|
+
handlers.commitManualSize("width", "200px"),
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
"rotation",
|
|
22
|
+
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
|
|
23
|
+
handlers.commitManualRotation("45"),
|
|
24
|
+
],
|
|
25
|
+
])("propagates blocked %s edits so the field can roll back", async (_name, commit) => {
|
|
26
|
+
const blocked = new GsapEditBlockedError("unroll-required");
|
|
27
|
+
const onCommitAnimatedProperty = vi.fn().mockRejectedValue(blocked);
|
|
28
|
+
const onSetManualOffset = vi.fn();
|
|
29
|
+
const onSetManualSize = vi.fn();
|
|
30
|
+
const onSetManualRotation = vi.fn();
|
|
31
|
+
const element = {
|
|
32
|
+
id: "box",
|
|
33
|
+
selector: "#box",
|
|
34
|
+
element: document.createElement("div"),
|
|
35
|
+
boundingBox: { width: 100, height: 100 },
|
|
36
|
+
} as unknown as DomEditSelection;
|
|
37
|
+
const handlers = createTransformCommitHandlers({
|
|
38
|
+
element,
|
|
39
|
+
styles: {},
|
|
40
|
+
hasGsapAnimation: true,
|
|
41
|
+
gsapAnimId: "#box-to-position",
|
|
42
|
+
gsapKeyframes: null,
|
|
43
|
+
currentPct: 0,
|
|
44
|
+
onCommitAnimatedProperty,
|
|
45
|
+
onAddKeyframe: undefined,
|
|
46
|
+
onSetManualOffset,
|
|
47
|
+
onSetManualSize,
|
|
48
|
+
onSetManualRotation,
|
|
49
|
+
showToast: vi.fn(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await expect(commit(handlers)).rejects.toBe(blocked);
|
|
53
|
+
expect(onSetManualOffset).not.toHaveBeenCalled();
|
|
54
|
+
expect(onSetManualSize).not.toHaveBeenCalled();
|
|
55
|
+
expect(onSetManualRotation).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
|
|
|
41
41
|
// Route a transform value into the GSAP animation (or a new keyframe) when the
|
|
42
42
|
// element is animated. Returns true when handled, so callers fall through to
|
|
43
43
|
// the manual-transform path only for non-animated elements.
|
|
44
|
-
const commitAnimatedTransformValue = (
|
|
44
|
+
const commitAnimatedTransformValue = async (
|
|
45
45
|
property: string,
|
|
46
46
|
value: number,
|
|
47
47
|
noCallbacksMessage: string,
|
|
48
|
-
): boolean => {
|
|
48
|
+
): Promise<boolean> => {
|
|
49
49
|
if (onCommitAnimatedProperty && hasGsapAnimation) {
|
|
50
|
-
|
|
50
|
+
await onCommitAnimatedProperty(element, property, value);
|
|
51
51
|
return true;
|
|
52
52
|
}
|
|
53
53
|
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
|
|
@@ -62,11 +62,11 @@ export function createTransformCommitHandlers({
|
|
|
62
62
|
return false;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
|
|
65
|
+
const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
|
|
66
66
|
const parsed = parsePxMetricValue(nextValue);
|
|
67
67
|
if (parsed == null) return;
|
|
68
68
|
if (
|
|
69
|
-
commitAnimatedTransformValue(
|
|
69
|
+
await commitAnimatedTransformValue(
|
|
70
70
|
axis,
|
|
71
71
|
parsed,
|
|
72
72
|
"Cannot edit position — animation callbacks not available",
|
|
@@ -74,20 +74,20 @@ export function createTransformCommitHandlers({
|
|
|
74
74
|
)
|
|
75
75
|
return;
|
|
76
76
|
const current = readStudioPathOffset(element.element);
|
|
77
|
-
|
|
77
|
+
await Promise.resolve(
|
|
78
78
|
onSetManualOffset(element, {
|
|
79
79
|
x: axis === "x" ? parsed : current.x,
|
|
80
80
|
y: axis === "y" ? parsed : current.y,
|
|
81
81
|
}),
|
|
82
|
-
)
|
|
82
|
+
);
|
|
83
83
|
};
|
|
84
84
|
|
|
85
85
|
// fallow-ignore-next-line complexity
|
|
86
|
-
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
|
|
86
|
+
const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
|
|
87
87
|
const parsed = parsePxMetricValue(nextValue);
|
|
88
88
|
if (parsed == null || parsed <= 0) return;
|
|
89
89
|
if (onCommitAnimatedProperty && hasGsapAnimation) {
|
|
90
|
-
|
|
90
|
+
await onCommitAnimatedProperty(element, axis, parsed);
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
93
|
if (hasGsapAnimation) {
|
|
@@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
|
|
|
103
103
|
current.height > 0
|
|
104
104
|
? current.height
|
|
105
105
|
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
|
|
106
|
-
|
|
106
|
+
await Promise.resolve(
|
|
107
107
|
onSetManualSize(element, {
|
|
108
108
|
width: axis === "width" ? parsed : width,
|
|
109
109
|
height: axis === "height" ? parsed : height,
|
|
110
110
|
}),
|
|
111
|
-
)
|
|
111
|
+
);
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
const commitManualRotation = (nextValue: string) => {
|
|
114
|
+
const commitManualRotation = async (nextValue: string) => {
|
|
115
115
|
const parsed = Number.parseFloat(nextValue);
|
|
116
116
|
if (!Number.isFinite(parsed)) return;
|
|
117
117
|
if (
|
|
118
|
-
commitAnimatedTransformValue(
|
|
118
|
+
await commitAnimatedTransformValue(
|
|
119
119
|
"rotation",
|
|
120
120
|
parsed,
|
|
121
121
|
"Cannot edit rotation — animation callbacks not available",
|
|
122
122
|
)
|
|
123
123
|
)
|
|
124
124
|
return;
|
|
125
|
-
|
|
125
|
+
await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
|
|
126
126
|
};
|
|
127
127
|
|
|
128
128
|
return { commitManualOffset, commitManualSize, commitManualRotation };
|
|
@@ -71,9 +71,15 @@ export interface PropertyPanelProps {
|
|
|
71
71
|
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
|
72
72
|
},
|
|
73
73
|
) => Promise<BackgroundRemovalResult>;
|
|
74
|
-
onSetManualOffset: (
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
onSetManualOffset: (
|
|
75
|
+
element: DomEditSelection,
|
|
76
|
+
next: { x: number; y: number },
|
|
77
|
+
) => void | Promise<void>;
|
|
78
|
+
onSetManualSize: (
|
|
79
|
+
element: DomEditSelection,
|
|
80
|
+
next: { width: number; height: number },
|
|
81
|
+
) => void | Promise<void>;
|
|
82
|
+
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
|
|
77
83
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
78
84
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
79
85
|
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
@@ -123,8 +129,8 @@ export interface PropertyPanelProps {
|
|
|
123
129
|
) => void;
|
|
124
130
|
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
|
125
131
|
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
126
|
-
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
127
132
|
onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
|
|
133
|
+
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
128
134
|
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
|
129
135
|
onCommitAnimatedProperty?: (
|
|
130
136
|
selection: DomEditSelection,
|
|
@@ -8,6 +8,30 @@ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction
|
|
|
8
8
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
9
|
|
|
10
10
|
describe("useInspectorGestureTransaction", () => {
|
|
11
|
+
it("restores the durable baseline when an inspector commit rejects", async () => {
|
|
12
|
+
const host = document.createElement("div");
|
|
13
|
+
const root = createRoot(host);
|
|
14
|
+
const onPreview = vi.fn();
|
|
15
|
+
const onCommit = vi.fn().mockRejectedValue(new Error("save failed"));
|
|
16
|
+
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
|
|
17
|
+
|
|
18
|
+
function Probe() {
|
|
19
|
+
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
act(() => root.render(<Probe />));
|
|
24
|
+
act(() => {
|
|
25
|
+
gesture?.preview(25);
|
|
26
|
+
gesture?.settle();
|
|
27
|
+
});
|
|
28
|
+
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25]);
|
|
29
|
+
await act(async () => Promise.resolve());
|
|
30
|
+
|
|
31
|
+
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25, 10]);
|
|
32
|
+
act(() => root.unmount());
|
|
33
|
+
});
|
|
34
|
+
|
|
11
35
|
it("keeps a new gesture active when the prior async commit is acknowledged", () => {
|
|
12
36
|
const host = document.createElement("div");
|
|
13
37
|
const root = createRoot(host);
|
|
@@ -34,4 +58,45 @@ describe("useInspectorGestureTransaction", () => {
|
|
|
34
58
|
|
|
35
59
|
act(() => root.unmount());
|
|
36
60
|
});
|
|
61
|
+
|
|
62
|
+
it("does not let an older rejected commit roll back a newer successful gesture", async () => {
|
|
63
|
+
const host = document.createElement("div");
|
|
64
|
+
const root = createRoot(host);
|
|
65
|
+
const onPreview = vi.fn();
|
|
66
|
+
let rejectFirst: ((error: Error) => void) | null = null;
|
|
67
|
+
const onCommit = vi
|
|
68
|
+
.fn()
|
|
69
|
+
.mockImplementationOnce((value: number) => {
|
|
70
|
+
onPreview(value);
|
|
71
|
+
return new Promise<void>((_resolve, reject) => {
|
|
72
|
+
rejectFirst = reject;
|
|
73
|
+
});
|
|
74
|
+
})
|
|
75
|
+
.mockImplementationOnce((value: number) => {
|
|
76
|
+
onPreview(value);
|
|
77
|
+
return Promise.resolve();
|
|
78
|
+
});
|
|
79
|
+
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
|
|
80
|
+
|
|
81
|
+
function Probe() {
|
|
82
|
+
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
act(() => root.render(<Probe />));
|
|
87
|
+
act(() => {
|
|
88
|
+
gesture?.preview(20);
|
|
89
|
+
gesture?.settle();
|
|
90
|
+
gesture?.preview(30);
|
|
91
|
+
gesture?.settle();
|
|
92
|
+
});
|
|
93
|
+
await act(async () => {
|
|
94
|
+
rejectFirst?.(new Error("old save failed"));
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
|
|
99
|
+
expect(onPreview).toHaveBeenLastCalledWith(30);
|
|
100
|
+
act(() => root.unmount());
|
|
101
|
+
});
|
|
37
102
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
+
function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
|
|
4
|
+
return Boolean(result && typeof result.then === "function");
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
/** One owner for continuous inspector edits: preview freely, persist once. */
|
|
4
8
|
export function useInspectorGestureTransaction<T>({
|
|
5
9
|
sourceValue,
|
|
@@ -8,44 +12,96 @@ export function useInspectorGestureTransaction<T>({
|
|
|
8
12
|
}: {
|
|
9
13
|
sourceValue: T;
|
|
10
14
|
onPreview: (value: T) => void;
|
|
11
|
-
onCommit: (value: T) => void
|
|
15
|
+
onCommit: (value: T) => void | Promise<void>;
|
|
12
16
|
}) {
|
|
13
17
|
const sourceRef = useRef(sourceValue);
|
|
14
18
|
const activeRef = useRef<{ before: T; latest: T } | null>(null);
|
|
15
19
|
const previewRef = useRef(onPreview);
|
|
16
20
|
const commitRef = useRef(onCommit);
|
|
17
|
-
|
|
21
|
+
const generationRef = useRef(0);
|
|
22
|
+
const pendingRef = useRef<{ before: T; latest: T } | null>(null);
|
|
23
|
+
const awaitingSourceAckRef = useRef<{ generation: number; value: T } | null>(null);
|
|
24
|
+
const lastSourceValueRef = useRef(sourceValue);
|
|
25
|
+
if (!Object.is(lastSourceValueRef.current, sourceValue)) {
|
|
26
|
+
lastSourceValueRef.current = sourceValue;
|
|
27
|
+
const matchesSourceAck = Object.is(awaitingSourceAckRef.current?.value, sourceValue);
|
|
28
|
+
const matchesOptimisticValue =
|
|
29
|
+
(activeRef.current && Object.is(activeRef.current.latest, sourceValue)) ||
|
|
30
|
+
(pendingRef.current && Object.is(pendingRef.current.latest, sourceValue)) ||
|
|
31
|
+
matchesSourceAck;
|
|
32
|
+
if (matchesSourceAck) awaitingSourceAckRef.current = null;
|
|
33
|
+
if (!matchesOptimisticValue) {
|
|
34
|
+
generationRef.current += 1;
|
|
35
|
+
activeRef.current = null;
|
|
36
|
+
pendingRef.current = null;
|
|
37
|
+
awaitingSourceAckRef.current = null;
|
|
38
|
+
sourceRef.current = sourceValue;
|
|
39
|
+
} else {
|
|
40
|
+
sourceRef.current = sourceValue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
18
43
|
previewRef.current = onPreview;
|
|
19
44
|
commitRef.current = onCommit;
|
|
20
45
|
|
|
21
46
|
const begin = useCallback(() => {
|
|
22
47
|
if (!activeRef.current) {
|
|
48
|
+
generationRef.current += 1;
|
|
23
49
|
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
|
|
24
50
|
}
|
|
25
51
|
}, []);
|
|
26
52
|
|
|
27
53
|
const preview = useCallback((value: T) => {
|
|
28
54
|
if (!activeRef.current) {
|
|
55
|
+
generationRef.current += 1;
|
|
29
56
|
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
|
|
30
57
|
}
|
|
31
58
|
activeRef.current.latest = value;
|
|
32
59
|
previewRef.current(value);
|
|
33
60
|
}, []);
|
|
34
61
|
|
|
62
|
+
const rollbackCommit = useCallback((active: { before: T; latest: T }, generation: number) => {
|
|
63
|
+
if (generation !== generationRef.current) return;
|
|
64
|
+
pendingRef.current = null;
|
|
65
|
+
if (awaitingSourceAckRef.current?.generation === generation) {
|
|
66
|
+
awaitingSourceAckRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
sourceRef.current = active.before;
|
|
69
|
+
previewRef.current(active.before);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
35
72
|
const settle = useCallback(() => {
|
|
36
73
|
const active = activeRef.current;
|
|
37
74
|
activeRef.current = null;
|
|
38
75
|
if (active && !Object.is(active.before, active.latest)) {
|
|
76
|
+
const generation = ++generationRef.current;
|
|
39
77
|
sourceRef.current = active.latest;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
pendingRef.current = active;
|
|
79
|
+
awaitingSourceAckRef.current = { generation, value: active.latest };
|
|
80
|
+
try {
|
|
81
|
+
const result = commitRef.current(active.latest);
|
|
82
|
+
if (isPromiseCommit(result)) {
|
|
83
|
+
void result.then(
|
|
84
|
+
() => {
|
|
85
|
+
if (generation === generationRef.current) pendingRef.current = null;
|
|
86
|
+
},
|
|
87
|
+
() => rollbackCommit(active, generation),
|
|
88
|
+
);
|
|
89
|
+
} else if (generation === generationRef.current) {
|
|
90
|
+
pendingRef.current = null;
|
|
91
|
+
// Synchronous inspector consumers historically restore their preview
|
|
92
|
+
// after persisting (color pickers close, curves release the pointer).
|
|
93
|
+
// Async source mutations keep the optimistic preview until the write
|
|
94
|
+
// resolves so they do not flash back to the baseline while pending.
|
|
95
|
+
previewRef.current(active.before);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
rollbackCommit(active, generation);
|
|
99
|
+
}
|
|
45
100
|
}
|
|
46
|
-
}, []);
|
|
101
|
+
}, [rollbackCommit]);
|
|
47
102
|
|
|
48
103
|
const cancel = useCallback(() => {
|
|
104
|
+
generationRef.current += 1;
|
|
49
105
|
const active = activeRef.current;
|
|
50
106
|
activeRef.current = null;
|
|
51
107
|
if (active && !Object.is(active.before, active.latest)) {
|
|
@@ -66,7 +122,7 @@ export function useInspectorGestureDraft<T>({
|
|
|
66
122
|
}: {
|
|
67
123
|
sourceValue: T;
|
|
68
124
|
onPreview: (value: T) => void;
|
|
69
|
-
onCommit: (value: T) => void
|
|
125
|
+
onCommit: (value: T) => void | Promise<void>;
|
|
70
126
|
}) {
|
|
71
127
|
const [draft, setDraft] = useState(sourceValue);
|
|
72
128
|
const transaction = useInspectorGestureTransaction({
|
|
@@ -77,7 +133,7 @@ export function useInspectorGestureDraft<T>({
|
|
|
77
133
|
},
|
|
78
134
|
onCommit: (next) => {
|
|
79
135
|
setDraft(next);
|
|
80
|
-
onCommit(next);
|
|
136
|
+
return onCommit(next);
|
|
81
137
|
},
|
|
82
138
|
});
|
|
83
139
|
|
|
@@ -317,17 +317,44 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
317
317
|
});
|
|
318
318
|
|
|
319
319
|
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
|
|
320
|
-
const
|
|
320
|
+
const circle: TimelineElement = {
|
|
321
|
+
...element,
|
|
322
|
+
id: "circle",
|
|
323
|
+
key: "scenes/main.html#circle",
|
|
324
|
+
domId: "circle",
|
|
325
|
+
sourceFile: "scenes/main.html",
|
|
326
|
+
};
|
|
327
|
+
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
|
|
328
|
+
const scaleAnimation: GsapAnimation = {
|
|
329
|
+
...otherKeyframedAnimation,
|
|
330
|
+
id: "circle-to-0-scale",
|
|
331
|
+
properties: {},
|
|
332
|
+
propertyGroup: "scale",
|
|
333
|
+
keyframes: {
|
|
334
|
+
format: "percentage",
|
|
335
|
+
keyframes: [
|
|
336
|
+
{ percentage: 0, properties: { scale: 1 } },
|
|
337
|
+
{ percentage: 100, properties: { scale: 2 } },
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
usePlayerStore.setState({
|
|
342
|
+
elements: [element, circle],
|
|
343
|
+
gsapAnimations: new Map([
|
|
344
|
+
["scenes/main.html#circle", [otherKeyframedAnimation, scaleAnimation]],
|
|
345
|
+
]),
|
|
346
|
+
});
|
|
347
|
+
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
|
|
321
348
|
const view = renderCallbacks();
|
|
322
349
|
|
|
323
350
|
await act(async () => {
|
|
324
|
-
view.callbacks.onDeleteAllKeyframes?.(circle);
|
|
351
|
+
view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id);
|
|
325
352
|
await Promise.resolve();
|
|
326
353
|
});
|
|
327
354
|
|
|
328
355
|
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
|
|
329
|
-
|
|
330
|
-
|
|
356
|
+
scaleAnimation.id,
|
|
357
|
+
circleSelection,
|
|
331
358
|
);
|
|
332
359
|
view.unmount();
|
|
333
360
|
});
|
|
@@ -360,6 +387,19 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
360
387
|
view.unmount();
|
|
361
388
|
});
|
|
362
389
|
|
|
390
|
+
it("does not delete a different lane when an explicit animation identity is stale", async () => {
|
|
391
|
+
const { circle } = arrangeClickedCircle();
|
|
392
|
+
const view = renderCallbacks();
|
|
393
|
+
|
|
394
|
+
await act(async () => {
|
|
395
|
+
view.callbacks.onDeleteAllKeyframes?.(circle, "missing-animation-id");
|
|
396
|
+
await Promise.resolve();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
|
|
400
|
+
view.unmount();
|
|
401
|
+
});
|
|
402
|
+
|
|
363
403
|
it("aborts every mutation when the clicked element resolves no selection", async () => {
|
|
364
404
|
const { circle } = arrangeClickedCircle();
|
|
365
405
|
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
|
@@ -391,6 +431,40 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
391
431
|
view.unmount();
|
|
392
432
|
});
|
|
393
433
|
|
|
434
|
+
it("does not delete a different keyframe when its explicit animation identity is stale", () => {
|
|
435
|
+
const view = renderCallbacks();
|
|
436
|
+
|
|
437
|
+
act(() => {
|
|
438
|
+
view.callbacks.onDeleteKeyframe?.("box", {
|
|
439
|
+
percentage: 100,
|
|
440
|
+
propertyGroup: "position",
|
|
441
|
+
tweenPercentage: 100,
|
|
442
|
+
animationId: "missing-animation-id",
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
|
447
|
+
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
|
|
448
|
+
view.unmount();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("does not move a different keyframe when its explicit animation identity is stale", async () => {
|
|
452
|
+
const view = renderCallbacks();
|
|
453
|
+
|
|
454
|
+
await act(async () => {
|
|
455
|
+
view.callbacks.onMoveKeyframeToPlayhead?.(element, {
|
|
456
|
+
percentage: 100,
|
|
457
|
+
propertyGroup: "position",
|
|
458
|
+
tweenPercentage: 100,
|
|
459
|
+
animationId: "missing-animation-id",
|
|
460
|
+
});
|
|
461
|
+
await Promise.resolve();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
|
|
465
|
+
view.unmount();
|
|
466
|
+
});
|
|
467
|
+
|
|
394
468
|
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
|
|
395
469
|
const { circle, selection } = arrangeClickedCircle();
|
|
396
470
|
const view = renderCallbacks();
|
|
@@ -189,17 +189,22 @@ export function useTimelineEditCallbacks({
|
|
|
189
189
|
onSplitElement: handleTimelineElementSplit,
|
|
190
190
|
onRazorSplit: handleRazorSplit,
|
|
191
191
|
onRazorSplitAll: handleRazorSplitAll,
|
|
192
|
-
onDeleteAllKeyframes: (element) => {
|
|
192
|
+
onDeleteAllKeyframes: (element, animationId) => {
|
|
193
193
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
194
194
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
195
195
|
// that the next drag adds to, flinging the element off-screen.
|
|
196
196
|
const elementKey = getTimelineElementIdentity(element);
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
197
|
+
// An explicit animation id scopes the delete to the lane whose menu was
|
|
198
|
+
// opened; without one this is the layer-wide action, and that means
|
|
199
|
+
// EVERY keyframed tween, not just the first. A layer with position AND
|
|
200
|
+
// opacity keyframes used to leave the second one keyframed, so "Delete
|
|
201
|
+
// All Keyframes" visibly did half the job. A stale id matches nothing
|
|
202
|
+
// and deletes nothing, which is the point: it never falls back to a
|
|
203
|
+
// lane the user did not click.
|
|
204
|
+
const animations = resolveElementAnimations(elementKey);
|
|
205
|
+
const anims = animationId
|
|
206
|
+
? animations.filter((animation) => animation.id === animationId)
|
|
207
|
+
: animations.filter((animation) => animation.keyframes);
|
|
203
208
|
if (anims.length === 0) return;
|
|
204
209
|
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
|
205
210
|
if (!selection) return;
|