@hyperframes/studio 0.7.59 → 0.7.61
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-Xvx2hkrc.js +459 -0
- package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
- package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
- package/dist/assets/index-BhWig0mx.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1441 -770
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -9
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +47 -28
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +44 -42
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
- package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.ts +8 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
- package/dist/assets/index-Dj5p8U_A.css +0 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from "react";
|
|
1
|
+
import { useState, useCallback, useMemo, useRef } from "react";
|
|
2
2
|
import type { EditingFile } from "../utils/studioHelpers";
|
|
3
3
|
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
|
|
4
4
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
@@ -7,8 +7,10 @@ import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
|
|
7
7
|
import {
|
|
8
8
|
createStudioSaveHttpError,
|
|
9
9
|
retryStudioSave,
|
|
10
|
+
StudioFileConflictError,
|
|
10
11
|
StudioSaveNetworkError,
|
|
11
12
|
} from "../utils/studioSaveDiagnostics";
|
|
13
|
+
import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
|
|
12
14
|
import { useFileTree } from "./useFileTree";
|
|
13
15
|
import { useEditorSave } from "./useEditorSave";
|
|
14
16
|
|
|
@@ -50,6 +52,17 @@ export function useFileManager({
|
|
|
50
52
|
projectIdRef.current = projectId;
|
|
51
53
|
|
|
52
54
|
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
55
|
+
const fileVersionScope = useMemo(
|
|
56
|
+
() => ({ projectId, versions: new Map<string, string | null>() }),
|
|
57
|
+
[projectId],
|
|
58
|
+
);
|
|
59
|
+
const fileVersions = fileVersionScope.versions;
|
|
60
|
+
const observeProjectFileVersion = useCallback(
|
|
61
|
+
(path: string, version: string | null) => {
|
|
62
|
+
fileVersions.set(path, version);
|
|
63
|
+
},
|
|
64
|
+
[fileVersions],
|
|
65
|
+
);
|
|
53
66
|
|
|
54
67
|
// ── File tree ──
|
|
55
68
|
|
|
@@ -73,17 +86,38 @@ export function useFileManager({
|
|
|
73
86
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
|
|
74
87
|
);
|
|
75
88
|
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
76
|
-
const data = (await response.json()) as { content?: string };
|
|
89
|
+
const data = (await response.json()) as { content?: string; version?: string };
|
|
77
90
|
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
91
|
+
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
78
92
|
return data.content;
|
|
79
93
|
},
|
|
80
|
-
[projectId],
|
|
94
|
+
[fileVersions, projectId],
|
|
81
95
|
);
|
|
82
96
|
|
|
83
97
|
const writeProjectFile = useCallback(
|
|
84
|
-
async (path: string, content: string): Promise<void> => {
|
|
98
|
+
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
85
99
|
if (!projectId) throw new Error("No active project");
|
|
86
100
|
const writeProjectId = projectId;
|
|
101
|
+
let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
|
|
102
|
+
if (expectedVersion === undefined) {
|
|
103
|
+
const preflight = await fetch(
|
|
104
|
+
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
105
|
+
);
|
|
106
|
+
if (preflight.ok) {
|
|
107
|
+
const data = (await preflight.json()) as { content?: string; version?: string };
|
|
108
|
+
throw new StudioFileConflictError({
|
|
109
|
+
filePath: path,
|
|
110
|
+
currentVersion: data.version ?? preflight.headers.get("etag"),
|
|
111
|
+
currentContent: data.content ?? null,
|
|
112
|
+
attemptedContent: content,
|
|
113
|
+
});
|
|
114
|
+
} else if (preflight.status === 404) {
|
|
115
|
+
expectedVersion = null;
|
|
116
|
+
} else {
|
|
117
|
+
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const writeToken = createStudioWriteToken();
|
|
87
121
|
await retryStudioSave(async () => {
|
|
88
122
|
let response: Response;
|
|
89
123
|
try {
|
|
@@ -91,7 +125,11 @@ export function useFileManager({
|
|
|
91
125
|
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
92
126
|
{
|
|
93
127
|
method: "PUT",
|
|
94
|
-
headers: {
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "text/plain",
|
|
130
|
+
"X-Hyperframes-Write-Token": writeToken,
|
|
131
|
+
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
|
132
|
+
},
|
|
95
133
|
body: content,
|
|
96
134
|
},
|
|
97
135
|
);
|
|
@@ -100,13 +138,35 @@ export function useFileManager({
|
|
|
100
138
|
cause: error,
|
|
101
139
|
});
|
|
102
140
|
}
|
|
141
|
+
if (response.status === 409) {
|
|
142
|
+
const conflict = (await response.json().catch(() => null)) as {
|
|
143
|
+
currentVersion?: string | null;
|
|
144
|
+
currentContent?: string | null;
|
|
145
|
+
} | null;
|
|
146
|
+
const currentVersion = conflict?.currentVersion ?? null;
|
|
147
|
+
if (currentVersion && conflict?.currentContent === content) {
|
|
148
|
+
fileVersions.set(path, currentVersion);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw new StudioFileConflictError({
|
|
152
|
+
filePath: path,
|
|
153
|
+
currentVersion,
|
|
154
|
+
currentContent: conflict?.currentContent ?? null,
|
|
155
|
+
attemptedContent: content,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
103
158
|
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
|
159
|
+
const result = (await response.json()) as { version?: string };
|
|
160
|
+
const version = result.version ?? response.headers.get("etag");
|
|
161
|
+
if (!version)
|
|
162
|
+
throw new Error(`Save response for ${path} did not include a content version`);
|
|
163
|
+
fileVersions.set(path, version);
|
|
104
164
|
});
|
|
105
165
|
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
|
106
166
|
setEditingFile({ path, content });
|
|
107
167
|
}
|
|
108
168
|
},
|
|
109
|
-
[projectId],
|
|
169
|
+
[fileVersions, projectId],
|
|
110
170
|
);
|
|
111
171
|
|
|
112
172
|
const updateEditingFileContent = useCallback((path: string, content: string) => {
|
|
@@ -122,10 +182,11 @@ export function useFileManager({
|
|
|
122
182
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
123
183
|
);
|
|
124
184
|
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
125
|
-
const data = (await response.json()) as { content?: string };
|
|
185
|
+
const data = (await response.json()) as { content?: string; version?: string };
|
|
186
|
+
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
126
187
|
return typeof data.content === "string" ? data.content : "";
|
|
127
188
|
},
|
|
128
|
-
[projectId],
|
|
189
|
+
[fileVersions, projectId],
|
|
129
190
|
);
|
|
130
191
|
|
|
131
192
|
// ── Editor save (debounced content change) ──
|
|
@@ -163,8 +224,9 @@ export function useFileManager({
|
|
|
163
224
|
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
|
|
164
225
|
return r.json();
|
|
165
226
|
})
|
|
166
|
-
.then((data: { content?: string }) => {
|
|
227
|
+
.then((data: { content?: string; version?: string }) => {
|
|
167
228
|
if (data.content != null) {
|
|
229
|
+
fileVersions.set(path, data.version ?? null);
|
|
168
230
|
setEditingFile({ path, content: data.content });
|
|
169
231
|
}
|
|
170
232
|
})
|
|
@@ -172,7 +234,7 @@ export function useFileManager({
|
|
|
172
234
|
showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
|
|
173
235
|
});
|
|
174
236
|
},
|
|
175
|
-
[showToast],
|
|
237
|
+
[fileVersions, showToast],
|
|
176
238
|
);
|
|
177
239
|
|
|
178
240
|
// ── Click-to-source ──
|
|
@@ -195,9 +257,10 @@ export function useFileManager({
|
|
|
195
257
|
signal: controller.signal,
|
|
196
258
|
})
|
|
197
259
|
.then((r) => r.json())
|
|
198
|
-
.then((data: { content?: string }) => {
|
|
260
|
+
.then((data: { content?: string; version?: string }) => {
|
|
199
261
|
if (requestId !== revealRequestIdRef.current) return;
|
|
200
262
|
if (data.content != null) {
|
|
263
|
+
fileVersions.set(sourceFile, data.version ?? null);
|
|
201
264
|
setEditingFile({ path: sourceFile, content: data.content });
|
|
202
265
|
const match = findTagByTarget(data.content, target);
|
|
203
266
|
setRevealSourceOffset(match ? match.start : null);
|
|
@@ -205,7 +268,7 @@ export function useFileManager({
|
|
|
205
268
|
})
|
|
206
269
|
.catch(() => {});
|
|
207
270
|
},
|
|
208
|
-
[editingFile?.content],
|
|
271
|
+
[editingFile?.content, fileVersions],
|
|
209
272
|
);
|
|
210
273
|
|
|
211
274
|
// ── Upload ──
|
|
@@ -434,6 +497,7 @@ export function useFileManager({
|
|
|
434
497
|
readProjectFile,
|
|
435
498
|
writeProjectFile,
|
|
436
499
|
readOptionalProjectFile,
|
|
500
|
+
observeProjectFileVersion,
|
|
437
501
|
updateEditingFileContent,
|
|
438
502
|
|
|
439
503
|
// Click-to-source
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePanelLayout } from "./usePanelLayout";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
vi.doUnmock("../components/editor/manualEditingAvailability");
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function renderPanelLayoutWith(hook: typeof usePanelLayout) {
|
|
17
|
+
const host = document.createElement("div");
|
|
18
|
+
document.body.append(host);
|
|
19
|
+
const root = createRoot(host);
|
|
20
|
+
let current: ReturnType<typeof usePanelLayout> | null = null;
|
|
21
|
+
|
|
22
|
+
function Harness() {
|
|
23
|
+
current = hook();
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
root.render(React.createElement(Harness));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
getState: (): ReturnType<typeof usePanelLayout> => {
|
|
33
|
+
if (!current) throw new Error("usePanelLayout did not render");
|
|
34
|
+
return current;
|
|
35
|
+
},
|
|
36
|
+
unmount: () => act(() => root.unmount()),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderPanelLayout() {
|
|
41
|
+
return renderPanelLayoutWith(usePanelLayout);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("usePanelLayout — right inspector panes", () => {
|
|
45
|
+
it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
|
|
46
|
+
const harness = renderPanelLayout();
|
|
47
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
48
|
+
|
|
49
|
+
act(() => harness.getState().toggleRightInspectorPane("layers"));
|
|
50
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
|
|
51
|
+
|
|
52
|
+
harness.unmount();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("toggleRightInspectorPane refuses to turn off the last remaining pane", () => {
|
|
56
|
+
const harness = renderPanelLayout();
|
|
57
|
+
act(() => harness.getState().toggleRightInspectorPane("design"));
|
|
58
|
+
// Only "design" was on; toggling it off would leave both false — guarded.
|
|
59
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
60
|
+
harness.unmount();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("setExclusiveRightInspectorPane is radio-style — selecting one turns the other off", () => {
|
|
64
|
+
const harness = renderPanelLayout();
|
|
65
|
+
act(() => harness.getState().toggleRightInspectorPane("layers"));
|
|
66
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
|
|
67
|
+
|
|
68
|
+
act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
|
|
69
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
|
|
70
|
+
|
|
71
|
+
act(() => harness.getState().setExclusiveRightInspectorPane("design"));
|
|
72
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
73
|
+
|
|
74
|
+
harness.unmount();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("setRightPanelTab additively opens a pane when the flat inspector is off (legacy behavior)", async () => {
|
|
78
|
+
vi.resetModules();
|
|
79
|
+
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|
|
80
|
+
const actual = await vi.importActual<
|
|
81
|
+
typeof import("../components/editor/manualEditingAvailability")
|
|
82
|
+
>("../components/editor/manualEditingAvailability");
|
|
83
|
+
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
|
|
84
|
+
});
|
|
85
|
+
const { usePanelLayout: usePanelLayoutFlatOff } = await import("./usePanelLayout");
|
|
86
|
+
const harness = renderPanelLayoutWith(usePanelLayoutFlatOff);
|
|
87
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
88
|
+
|
|
89
|
+
act(() => harness.getState().setRightPanelTab("layers"));
|
|
90
|
+
// Legacy (split-view) behavior: additive, both panes end up open.
|
|
91
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
|
|
92
|
+
|
|
93
|
+
harness.unmount();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|
|
99
|
+
const actual = await vi.importActual<
|
|
100
|
+
typeof import("../components/editor/manualEditingAvailability")
|
|
101
|
+
>("../components/editor/manualEditingAvailability");
|
|
102
|
+
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
|
|
103
|
+
});
|
|
104
|
+
const { usePanelLayout: usePanelLayoutFlatOn } = await import("./usePanelLayout");
|
|
105
|
+
const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
|
|
106
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
107
|
+
|
|
108
|
+
// Element-select / block-params-close / header Inspector-button callers
|
|
109
|
+
// all reach setRightPanelTab directly, not through the in-panel tab
|
|
110
|
+
// click's own setExclusiveRightInspectorPane call — this must still
|
|
111
|
+
// enforce exclusivity under the flat flag, or both tabs end up
|
|
112
|
+
// highlighted while only one pane actually renders.
|
|
113
|
+
act(() => harness.getState().setRightPanelTab("layers"));
|
|
114
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
|
|
115
|
+
|
|
116
|
+
harness.unmount();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "../utils/studioHelpers";
|
|
7
7
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
8
8
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
|
+
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
9
10
|
|
|
10
11
|
export interface InitialPanelLayoutState {
|
|
11
12
|
rightCollapsed?: boolean | null;
|
|
@@ -80,7 +81,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
80
81
|
const trackedSetRightPanelTab = useCallback(
|
|
81
82
|
(tab: RightPanelTab) => {
|
|
82
83
|
if (tab === "design" || tab === "layers") {
|
|
83
|
-
|
|
84
|
+
// Flat inspector: Layers always renders full-height by itself (see
|
|
85
|
+
// StudioRightPanel's render gate), so this MUST land on the same
|
|
86
|
+
// radio-style exclusivity setExclusiveRightInspectorPane enforces for
|
|
87
|
+
// the direct in-panel tab click — every OTHER path that reaches here
|
|
88
|
+
// (element select, closing block-params, the header Inspector
|
|
89
|
+
// button, and this function's own callers outside an active
|
|
90
|
+
// inspector tab) would otherwise additively leave both panes `true`
|
|
91
|
+
// and reproduce the "both tabs highlight, only one renders" bug this
|
|
92
|
+
// still-additive branch used to cause under the flat flag.
|
|
93
|
+
setRightInspectorPanes(
|
|
94
|
+
STUDIO_FLAT_INSPECTOR_ENABLED
|
|
95
|
+
? { design: tab === "design", layers: tab === "layers" }
|
|
96
|
+
: (panes) => ({ ...panes, [tab]: true }),
|
|
97
|
+
);
|
|
84
98
|
}
|
|
85
99
|
setRightPanelTab(tab);
|
|
86
100
|
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
@@ -96,6 +110,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
96
110
|
});
|
|
97
111
|
}, []);
|
|
98
112
|
|
|
113
|
+
// Radio-style variant for the flat inspector: Layers always renders full-
|
|
114
|
+
// height by itself there (never split-shared with Design), so leaving both
|
|
115
|
+
// panes independently toggleable would highlight both tabs as "active"
|
|
116
|
+
// while only one actually shows. Selecting one turns the other off.
|
|
117
|
+
const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => {
|
|
118
|
+
setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" });
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
99
121
|
return {
|
|
100
122
|
leftWidth,
|
|
101
123
|
setLeftWidth,
|
|
@@ -109,6 +131,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
109
131
|
setRightPanelTab: trackedSetRightPanelTab,
|
|
110
132
|
rightInspectorPanes,
|
|
111
133
|
toggleRightInspectorPane,
|
|
134
|
+
setExclusiveRightInspectorPane,
|
|
112
135
|
toggleLeftSidebar,
|
|
113
136
|
handlePanelResizeStart,
|
|
114
137
|
handlePanelResizeMove,
|
|
@@ -133,7 +133,10 @@ export function usePreviewPersistence({
|
|
|
133
133
|
if (!domEditSaveQueueRef.current) {
|
|
134
134
|
domEditSaveQueueRef.current = createDomEditSaveQueue({
|
|
135
135
|
onOpen: (event) => {
|
|
136
|
-
const message =
|
|
136
|
+
const message =
|
|
137
|
+
event.statusCode === 409
|
|
138
|
+
? "Save paused: this file changed elsewhere. Reload and review the latest version before reapplying your edit."
|
|
139
|
+
: "Auto-save is paused. Check your connection.";
|
|
137
140
|
setDomEditSaveQueuePaused(message);
|
|
138
141
|
showToastRef.current(message, "error");
|
|
139
142
|
trackStudioEvent("save_queue_paused", {
|
|
@@ -76,9 +76,10 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
76
76
|
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
|
77
77
|
// disk, returns the final content.
|
|
78
78
|
disk["index.html"] = SPLIT_GSAP;
|
|
79
|
-
|
|
79
|
+
const version = `"test-gsap-${SPLIT_GSAP.length}"`;
|
|
80
|
+
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
|
|
80
81
|
status: 200,
|
|
81
|
-
headers: { "Content-Type": "application/json" },
|
|
82
|
+
headers: { "Content-Type": "application/json", ETag: version },
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
// The fixture has no GSAP script — mirror the server's 400 response.
|
|
@@ -89,9 +90,16 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
89
90
|
}
|
|
90
91
|
if (u.includes("/file-mutations/split-element/")) {
|
|
91
92
|
disk["index.html"] = SPLIT;
|
|
93
|
+
const version = `"test-split-${SPLIT.length}"`;
|
|
92
94
|
return new Response(
|
|
93
|
-
JSON.stringify({
|
|
94
|
-
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
ok: true,
|
|
97
|
+
changed: true,
|
|
98
|
+
content: SPLIT,
|
|
99
|
+
newId: "clip1-split",
|
|
100
|
+
version,
|
|
101
|
+
}),
|
|
102
|
+
{ status: 200, headers: { "Content-Type": "application/json", ETag: version } },
|
|
95
103
|
);
|
|
96
104
|
}
|
|
97
105
|
if (u.includes("/files/")) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../player";
|
|
6
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
7
|
+
|
|
8
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("useRazorSplit mutation versions", () => {
|
|
18
|
+
afterEach(() => vi.restoreAllMocks());
|
|
19
|
+
|
|
20
|
+
it("observes each out-of-band mutation version before the OCC writer runs", async () => {
|
|
21
|
+
const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
|
|
22
|
+
const htmlSplit =
|
|
23
|
+
'<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
|
|
24
|
+
const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
|
|
25
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
26
|
+
const url = String(input);
|
|
27
|
+
if (url.includes("/files/")) return jsonResponse({ content: original });
|
|
28
|
+
if (url.includes("/file-mutations/split-element/")) {
|
|
29
|
+
return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
|
|
30
|
+
}
|
|
31
|
+
if (url.includes("/gsap-mutations/")) {
|
|
32
|
+
return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const order: string[] = [];
|
|
38
|
+
const observeProjectFileVersion = vi.fn((path: string, version: string | null) => {
|
|
39
|
+
order.push(`observe:${path}:${version}`);
|
|
40
|
+
});
|
|
41
|
+
const writeProjectFile = vi.fn(async () => {
|
|
42
|
+
order.push("write");
|
|
43
|
+
});
|
|
44
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
|
|
46
|
+
const container = document.createElement("div");
|
|
47
|
+
document.body.appendChild(container);
|
|
48
|
+
const root = createRoot(container);
|
|
49
|
+
|
|
50
|
+
function Harness() {
|
|
51
|
+
split = useRazorSplit({
|
|
52
|
+
projectId: "p1",
|
|
53
|
+
activeCompPath: "index.html",
|
|
54
|
+
showToast: vi.fn(),
|
|
55
|
+
writeProjectFile,
|
|
56
|
+
observeProjectFileVersion,
|
|
57
|
+
recordEdit,
|
|
58
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
59
|
+
reloadPreview: vi.fn(),
|
|
60
|
+
}).handleRazorSplit;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await split?.(
|
|
67
|
+
{
|
|
68
|
+
id: "clip",
|
|
69
|
+
domId: "clip",
|
|
70
|
+
hfId: "clip",
|
|
71
|
+
tag: "div",
|
|
72
|
+
start: 0,
|
|
73
|
+
duration: 4,
|
|
74
|
+
track: 0,
|
|
75
|
+
timingSource: "authored",
|
|
76
|
+
},
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
|
|
82
|
+
expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
|
|
83
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
84
|
+
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
container.remove();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -36,10 +36,11 @@ export function createSplitFetchMock(
|
|
|
36
36
|
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
|
|
37
37
|
// Return content that differs from the original so `changed` is true.
|
|
38
38
|
const after = `${disk[path]}<!--split-->`;
|
|
39
|
+
const version = `"test-${path}-${after.length}"`;
|
|
39
40
|
disk[path] = after; // server writes the split to disk
|
|
40
|
-
return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
|
|
41
|
+
return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
|
|
41
42
|
status: 200,
|
|
42
|
-
headers: { "Content-Type": "application/json" },
|
|
43
|
+
headers: { "Content-Type": "application/json", ETag: version },
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
if (u.includes("/files/")) {
|