@hyperframes/studio 0.7.92 → 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-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.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-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- 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/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- 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 +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- 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/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/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/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- 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/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- 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/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/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -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/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.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;
|
|
@@ -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
|
|
|
@@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
|
|
|
8
8
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
9
9
|
import { ContextMenu } from "./AssetContextMenu";
|
|
10
10
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
11
|
+
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
|
11
12
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
12
13
|
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
13
14
|
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
|
|
@@ -133,7 +134,7 @@ export function AssetCard({
|
|
|
133
134
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
134
135
|
|
|
135
136
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
136
|
-
const
|
|
137
|
+
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
|
|
137
138
|
const elements = usePlayerStore((s) => s.elements);
|
|
138
139
|
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
139
140
|
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
@@ -158,7 +159,7 @@ export function AssetCard({
|
|
|
158
159
|
const clipKey = clip.key ?? clip.id;
|
|
159
160
|
setSelectedElementId(clipKey);
|
|
160
161
|
// Scroll the timeline so the selected clip is actually visible.
|
|
161
|
-
|
|
162
|
+
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
162
163
|
return;
|
|
163
164
|
}
|
|
164
165
|
}
|
|
@@ -171,7 +172,7 @@ export function AssetCard({
|
|
|
171
172
|
asset,
|
|
172
173
|
projectId,
|
|
173
174
|
setSelectedElementId,
|
|
174
|
-
|
|
175
|
+
requestTimelineFocus,
|
|
175
176
|
setPreviewAsset,
|
|
176
177
|
clearPreviewAsset,
|
|
177
178
|
],
|
|
@@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
|
|
6
6
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
7
7
|
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
8
8
|
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
9
|
+
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
|
9
10
|
|
|
10
11
|
export function AudioRow({
|
|
11
12
|
projectId,
|
|
@@ -43,7 +44,7 @@ export function AudioRow({
|
|
|
43
44
|
// CapCut-style click behavior: drag-threshold gate.
|
|
44
45
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
45
46
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
46
|
-
const
|
|
47
|
+
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
|
|
47
48
|
const elements = usePlayerStore((s) => s.elements);
|
|
48
49
|
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
49
50
|
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
@@ -67,7 +68,7 @@ export function AudioRow({
|
|
|
67
68
|
const clipKey = clip.key ?? clip.id;
|
|
68
69
|
setSelectedElementId(clipKey);
|
|
69
70
|
// Scroll the timeline so the selected clip is actually visible.
|
|
70
|
-
|
|
71
|
+
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
@@ -80,7 +81,7 @@ export function AudioRow({
|
|
|
80
81
|
asset,
|
|
81
82
|
projectId,
|
|
82
83
|
setSelectedElementId,
|
|
83
|
-
|
|
84
|
+
requestTimelineFocus,
|
|
84
85
|
setPreviewAsset,
|
|
85
86
|
clearPreviewAsset,
|
|
86
87
|
],
|
|
@@ -27,9 +27,13 @@ export function FileManagerProvider({
|
|
|
27
27
|
editingPathRef,
|
|
28
28
|
projectIdRef,
|
|
29
29
|
saveRafRef,
|
|
30
|
+
flushPendingSourceSave,
|
|
31
|
+
discardPendingSourceSave,
|
|
32
|
+
getPendingSourceCandidate,
|
|
30
33
|
importedFontAssetsRef,
|
|
31
34
|
readProjectFile,
|
|
32
35
|
writeProjectFile,
|
|
36
|
+
overwriteExternalConflict,
|
|
33
37
|
readOptionalProjectFile,
|
|
34
38
|
observeProjectFileVersion,
|
|
35
39
|
updateEditingFileContent,
|
|
@@ -67,9 +71,13 @@ export function FileManagerProvider({
|
|
|
67
71
|
editingPathRef,
|
|
68
72
|
projectIdRef,
|
|
69
73
|
saveRafRef,
|
|
74
|
+
flushPendingSourceSave,
|
|
75
|
+
discardPendingSourceSave,
|
|
76
|
+
getPendingSourceCandidate,
|
|
70
77
|
importedFontAssetsRef,
|
|
71
78
|
readProjectFile,
|
|
72
79
|
writeProjectFile,
|
|
80
|
+
overwriteExternalConflict,
|
|
73
81
|
readOptionalProjectFile,
|
|
74
82
|
observeProjectFileVersion,
|
|
75
83
|
updateEditingFileContent,
|
|
@@ -101,9 +109,13 @@ export function FileManagerProvider({
|
|
|
101
109
|
editingPathRef,
|
|
102
110
|
projectIdRef,
|
|
103
111
|
saveRafRef,
|
|
112
|
+
flushPendingSourceSave,
|
|
113
|
+
discardPendingSourceSave,
|
|
114
|
+
getPendingSourceCandidate,
|
|
104
115
|
importedFontAssetsRef,
|
|
105
116
|
readProjectFile,
|
|
106
117
|
writeProjectFile,
|
|
118
|
+
overwriteExternalConflict,
|
|
107
119
|
readOptionalProjectFile,
|
|
108
120
|
observeProjectFileVersion,
|
|
109
121
|
updateEditingFileContent,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
describe("external file-change subscription ownership", () => {
|
|
5
|
+
it("has one subscriber instead of independent Preview and SDK listeners", () => {
|
|
6
|
+
const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
|
|
7
|
+
const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
|
|
8
|
+
const coordinator = readFileSync(
|
|
9
|
+
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
|
10
|
+
"utf8",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(preview).not.toContain('hot.on("hf:file-change"');
|
|
14
|
+
expect(sdk).not.toContain('hot.on("hf:file-change"');
|
|
15
|
+
expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";
|
|
3
|
+
|
|
4
|
+
describe("external file reload bus", () => {
|
|
5
|
+
it("lets the sole watcher reload every SDK owner of the changed path", () => {
|
|
6
|
+
const first = vi.fn();
|
|
7
|
+
const second = vi.fn();
|
|
8
|
+
const removeFirst = addExternalFileReloadListener(first);
|
|
9
|
+
const removeSecond = addExternalFileReloadListener(second);
|
|
10
|
+
|
|
11
|
+
notifyExternalFileReload("scenes/card.html");
|
|
12
|
+
expect(first).toHaveBeenCalledWith("scenes/card.html");
|
|
13
|
+
expect(second).toHaveBeenCalledWith("scenes/card.html");
|
|
14
|
+
|
|
15
|
+
removeFirst();
|
|
16
|
+
removeSecond();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("isolates a broken listener so later SDK owners still reload", () => {
|
|
20
|
+
const broken = vi.fn(() => {
|
|
21
|
+
throw new Error("stale owner");
|
|
22
|
+
});
|
|
23
|
+
const healthy = vi.fn();
|
|
24
|
+
const removeBroken = addExternalFileReloadListener(broken);
|
|
25
|
+
const removeHealthy = addExternalFileReloadListener(healthy);
|
|
26
|
+
|
|
27
|
+
expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
|
|
28
|
+
expect(broken).toHaveBeenCalledWith("scenes/card.html");
|
|
29
|
+
expect(healthy).toHaveBeenCalledWith("scenes/card.html");
|
|
30
|
+
|
|
31
|
+
removeBroken();
|
|
32
|
+
removeHealthy();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type ExternalFileReloadListener = (path: string) => void;
|
|
2
|
+
|
|
3
|
+
const listeners = new Set<ExternalFileReloadListener>();
|
|
4
|
+
|
|
5
|
+
export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
|
|
6
|
+
listeners.add(listener);
|
|
7
|
+
return () => listeners.delete(listener);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function notifyExternalFileReload(path: string): void {
|
|
11
|
+
for (const listener of listeners) {
|
|
12
|
+
try {
|
|
13
|
+
listener(path);
|
|
14
|
+
} catch {
|
|
15
|
+
// A stale SDK owner must not prevent sibling owners from reloading.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|