@hyperframes/studio 0.8.53 → 0.8.55
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-DKWM-Ons.js → hyperframes-player-BjAGc2ID.js} +1 -1
- package/dist/assets/{index-Y4OnpTEM.js → index-BEIRytvV.js} +1 -1
- package/dist/assets/{index-UvW6BEwf.js → index-DaNpNFJC.js} +1 -1
- package/dist/assets/{index-HfP3sap2.js → index-TcBZyzNA.js} +191 -191
- package/dist/index.d.ts +440 -10
- package/dist/index.html +1 -1
- package/dist/index.js +1891 -1544
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/hooks/gsapDragCommit.ts +1 -1
- package/src/hooks/gsapEditOutcome.test.ts +115 -0
- package/src/hooks/gsapEditOutcome.ts +39 -10
- package/src/hooks/gsapResizeIntercept.test.ts +42 -3
- package/src/hooks/gsapResizeIntercept.ts +12 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
- package/src/hooks/gsapRuntimeBridge.ts +14 -3
- package/src/hooks/timelineAudioGroupCreate.ts +17 -1
- package/src/hooks/timelineAudioGroupVolume.ts +35 -2
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/timelineElementFxAttribute.ts +34 -2
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
- package/src/hooks/useTimelineAssetDropOps.ts +40 -3
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +481 -4
- package/src/hooks/useTimelineEditing.ts +151 -27
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +40 -2
- package/src/player/components/Timeline.tsx +6 -6
- package/src/player/components/TimelineTypes.ts +3 -0
- package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
- package/src/player/components/usePublishRangeSelection.ts +42 -0
- package/src/player/index.ts +1 -0
- package/src/player/store/playerStore.ts +8 -51
- package/src/player/store/rangeSelectionSlice.ts +13 -0
- package/src/player/store/timelineResetState.ts +51 -0
- package/src/utils/studioSaveDiagnostics.ts +12 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* shared core `setAudioGroupAttribute` also uses.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useCallback } from "react";
|
|
10
|
+
import { useCallback, useRef } from "react";
|
|
11
11
|
import type { TimelineElement } from "../player";
|
|
12
12
|
import {
|
|
13
13
|
buildPatchTarget,
|
|
@@ -32,6 +32,14 @@ function patchLiveElementAttribute(
|
|
|
32
32
|
else target.setAttribute(attr, value);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function elementAttributeLiveKey(
|
|
36
|
+
element: TimelineElement,
|
|
37
|
+
activeCompPath: string | null,
|
|
38
|
+
attr: string,
|
|
39
|
+
): string {
|
|
40
|
+
return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
interface SetElementAttributeInput {
|
|
36
44
|
projectId: string;
|
|
37
45
|
activeCompPath: string | null;
|
|
@@ -92,13 +100,35 @@ export function useSetElementAttribute({
|
|
|
92
100
|
value: string | null,
|
|
93
101
|
label: string,
|
|
94
102
|
) => Promise<void>;
|
|
103
|
+
revertLive: (element: TimelineElement, attr: string) => void;
|
|
95
104
|
} {
|
|
105
|
+
const liveBeforeRef = useRef(new Map<string, string | null>());
|
|
96
106
|
const setLive = useCallback(
|
|
97
107
|
(element: TimelineElement, attr: string, value: string | null) => {
|
|
108
|
+
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
109
|
+
const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
|
|
110
|
+
if (!liveBeforeRef.current.has(key)) {
|
|
111
|
+
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
|
112
|
+
}
|
|
98
113
|
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
|
99
114
|
},
|
|
100
115
|
[previewIframeRef, activeCompPath],
|
|
101
116
|
);
|
|
117
|
+
const revertLive = useCallback(
|
|
118
|
+
(element: TimelineElement, attr: string) => {
|
|
119
|
+
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
120
|
+
if (!liveBeforeRef.current.has(key)) return;
|
|
121
|
+
patchLiveElementAttribute(
|
|
122
|
+
previewIframeRef.current,
|
|
123
|
+
element,
|
|
124
|
+
attr,
|
|
125
|
+
liveBeforeRef.current.get(key) ?? null,
|
|
126
|
+
activeCompPath,
|
|
127
|
+
);
|
|
128
|
+
liveBeforeRef.current.delete(key);
|
|
129
|
+
},
|
|
130
|
+
[previewIframeRef, activeCompPath],
|
|
131
|
+
);
|
|
102
132
|
const setQuiet = useCallback(
|
|
103
133
|
async (element: TimelineElement, attr: string, value: string | null, label: string) => {
|
|
104
134
|
if (isRecordingRef?.current) {
|
|
@@ -120,10 +150,12 @@ export function useSetElementAttribute({
|
|
|
120
150
|
recordEdit,
|
|
121
151
|
pendingTimelineEditPathRef,
|
|
122
152
|
});
|
|
153
|
+
liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
|
|
123
154
|
} catch (error) {
|
|
124
155
|
console.error("[Timeline] Failed to set element attribute", error);
|
|
125
156
|
const message = error instanceof Error ? error.message : "Failed to update effect";
|
|
126
157
|
showToast(message);
|
|
158
|
+
liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
|
|
127
159
|
}
|
|
128
160
|
},
|
|
129
161
|
[
|
|
@@ -137,5 +169,5 @@ export function useSetElementAttribute({
|
|
|
137
169
|
projectIdRef,
|
|
138
170
|
],
|
|
139
171
|
);
|
|
140
|
-
return { setLive, setQuiet };
|
|
172
|
+
return { setLive, setQuiet, revertLive };
|
|
141
173
|
}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import { useState, useCallback,
|
|
1
|
+
import { useState, useCallback, 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";
|
|
5
5
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
6
|
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
|
7
|
-
import {
|
|
8
|
-
createStudioSaveHttpError,
|
|
9
|
-
retryStudioSave,
|
|
10
|
-
StudioFileConflictError,
|
|
11
|
-
StudioSaveNetworkError,
|
|
12
|
-
} from "../utils/studioSaveDiagnostics";
|
|
13
|
-
import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
7
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
14
8
|
import { useFileTree } from "./useFileTree";
|
|
15
9
|
import { useEditorSave } from "./useEditorSave";
|
|
10
|
+
import { useProjectFileWriter } from "./useProjectFileWriter";
|
|
16
11
|
|
|
17
12
|
// ── Types ──
|
|
18
13
|
|
|
@@ -50,17 +45,13 @@ export function useFileManager({
|
|
|
50
45
|
projectIdRef.current = projectId;
|
|
51
46
|
|
|
52
47
|
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
fileVersions.set(path, version);
|
|
61
|
-
},
|
|
62
|
-
[fileVersions],
|
|
63
|
-
);
|
|
48
|
+
const {
|
|
49
|
+
fileVersions,
|
|
50
|
+
readProjectFile,
|
|
51
|
+
writeProjectFile: writeProjectFileRaw,
|
|
52
|
+
readOptionalProjectFile,
|
|
53
|
+
observeProjectFileVersion,
|
|
54
|
+
} = useProjectFileWriter({ projectId });
|
|
64
55
|
|
|
65
56
|
// ── File tree ──
|
|
66
57
|
|
|
@@ -74,117 +65,24 @@ export function useFileManager({
|
|
|
74
65
|
fontAssets,
|
|
75
66
|
} = useFileTree({ projectId, projectIdRef });
|
|
76
67
|
|
|
77
|
-
// ── Core file I/O ──
|
|
78
|
-
|
|
79
|
-
const readProjectFile = useCallback(
|
|
80
|
-
async (path: string): Promise<string> => {
|
|
81
|
-
if (!projectId) throw new Error("No active project");
|
|
82
|
-
const response = await fetch(
|
|
83
|
-
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
|
|
84
|
-
);
|
|
85
|
-
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
86
|
-
const data = (await response.json()) as { content?: string; version?: string };
|
|
87
|
-
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
88
|
-
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
89
|
-
return data.content;
|
|
90
|
-
},
|
|
91
|
-
[fileVersions, projectId],
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const writeProjectFile = useCallback(
|
|
95
|
-
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
96
|
-
if (!projectId) throw new Error("No active project");
|
|
97
|
-
const writeProjectId = projectId;
|
|
98
|
-
let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
|
|
99
|
-
if (expectedVersion === undefined) {
|
|
100
|
-
const preflight = await fetch(
|
|
101
|
-
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
102
|
-
);
|
|
103
|
-
if (preflight.ok) {
|
|
104
|
-
const data = (await preflight.json()) as { content?: string; version?: string };
|
|
105
|
-
throw new StudioFileConflictError({
|
|
106
|
-
filePath: path,
|
|
107
|
-
currentVersion: data.version ?? preflight.headers.get("etag"),
|
|
108
|
-
currentContent: data.content ?? null,
|
|
109
|
-
attemptedContent: content,
|
|
110
|
-
});
|
|
111
|
-
} else if (preflight.status === 404) {
|
|
112
|
-
expectedVersion = null;
|
|
113
|
-
} else {
|
|
114
|
-
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
await retryStudioSave(async () => {
|
|
118
|
-
// Each request gets its own receipt identity. If a committed request loses its response,
|
|
119
|
-
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
|
120
|
-
let response: Response;
|
|
121
|
-
try {
|
|
122
|
-
response = await fetch(
|
|
123
|
-
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
124
|
-
{
|
|
125
|
-
method: "PUT",
|
|
126
|
-
headers: {
|
|
127
|
-
"Content-Type": "text/plain",
|
|
128
|
-
...studioWriteHeaders(),
|
|
129
|
-
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
|
130
|
-
},
|
|
131
|
-
body: content,
|
|
132
|
-
},
|
|
133
|
-
);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
|
|
136
|
-
cause: error,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
if (response.status === 409) {
|
|
140
|
-
const conflict = (await response.json().catch(() => null)) as {
|
|
141
|
-
currentVersion?: string | null;
|
|
142
|
-
currentContent?: string | null;
|
|
143
|
-
} | null;
|
|
144
|
-
const currentVersion = conflict?.currentVersion ?? null;
|
|
145
|
-
if (currentVersion && conflict?.currentContent === content) {
|
|
146
|
-
fileVersions.set(path, currentVersion);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
throw new StudioFileConflictError({
|
|
150
|
-
filePath: path,
|
|
151
|
-
currentVersion,
|
|
152
|
-
currentContent: conflict?.currentContent ?? null,
|
|
153
|
-
attemptedContent: content,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
|
157
|
-
const result = (await response.json()) as { version?: string };
|
|
158
|
-
const version = result.version ?? response.headers.get("etag");
|
|
159
|
-
if (!version)
|
|
160
|
-
throw new Error(`Save response for ${path} did not include a content version`);
|
|
161
|
-
fileVersions.set(path, version);
|
|
162
|
-
});
|
|
163
|
-
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
|
164
|
-
setEditingFile({ path, content });
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
[fileVersions, projectId],
|
|
168
|
-
);
|
|
169
|
-
|
|
170
68
|
const updateEditingFileContent = useCallback((path: string, content: string) => {
|
|
171
69
|
if (editingPathRef.current === path) {
|
|
172
70
|
setEditingFile({ path, content });
|
|
173
71
|
}
|
|
174
72
|
}, []);
|
|
175
73
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
74
|
+
// Studio's own editor tab mirrors whatever it just wrote, on top of the
|
|
75
|
+
// shared writer — a host mounting useProjectFileWriter directly has no
|
|
76
|
+
// editor tab and does not need this.
|
|
77
|
+
const writeProjectFile = useCallback(
|
|
78
|
+
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
79
|
+
const writeProjectId = projectIdRef.current;
|
|
80
|
+
await writeProjectFileRaw(path, content, expectedContent);
|
|
81
|
+
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
|
82
|
+
setEditingFile({ path, content });
|
|
83
|
+
}
|
|
186
84
|
},
|
|
187
|
-
[
|
|
85
|
+
[writeProjectFileRaw],
|
|
188
86
|
);
|
|
189
87
|
|
|
190
88
|
// ── Editor save (debounced content change) ──
|
|
@@ -33,7 +33,7 @@ interface ApplyCallbacks {
|
|
|
33
33
|
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
interface UsePersistentEditHistoryOptions {
|
|
36
|
+
export interface UsePersistentEditHistoryOptions {
|
|
37
37
|
projectId: string | null;
|
|
38
38
|
storage?: EditHistoryStorageAdapter;
|
|
39
39
|
now?: () => number;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createStudioSaveHttpError,
|
|
4
|
+
retryStudioSave,
|
|
5
|
+
StudioFileConflictError,
|
|
6
|
+
StudioSaveNetworkError,
|
|
7
|
+
} from "../utils/studioSaveDiagnostics";
|
|
8
|
+
import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
9
|
+
|
|
10
|
+
export interface UseProjectFileWriterOptions {
|
|
11
|
+
projectId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The etag-guarded project-file read/write pair every Studio save path
|
|
15
|
+
// shares. A host mounting hand editing outside Studio needs only this, not
|
|
16
|
+
// the full file manager (file tree, editor tabs, uploads, font assets).
|
|
17
|
+
export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions) {
|
|
18
|
+
const fileVersionScope = useMemo(
|
|
19
|
+
() => ({ projectId, versions: new Map<string, string | null>() }),
|
|
20
|
+
[projectId],
|
|
21
|
+
);
|
|
22
|
+
const fileVersions = fileVersionScope.versions;
|
|
23
|
+
|
|
24
|
+
const observeProjectFileVersion = useCallback(
|
|
25
|
+
(path: string, version: string | null) => {
|
|
26
|
+
fileVersions.set(path, version);
|
|
27
|
+
},
|
|
28
|
+
[fileVersions],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const readProjectFile = useCallback(
|
|
32
|
+
async (path: string): Promise<string> => {
|
|
33
|
+
if (!projectId) throw new Error("No active project");
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
|
|
36
|
+
);
|
|
37
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
38
|
+
const data = (await response.json()) as { content?: string; version?: string };
|
|
39
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
40
|
+
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
41
|
+
return data.content;
|
|
42
|
+
},
|
|
43
|
+
[fileVersions, projectId],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const readOptionalProjectFile = useCallback(
|
|
47
|
+
async (path: string): Promise<string> => {
|
|
48
|
+
if (!projectId) throw new Error("No active project");
|
|
49
|
+
const response = await fetch(
|
|
50
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
51
|
+
);
|
|
52
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
53
|
+
const data = (await response.json()) as { content?: string; version?: string };
|
|
54
|
+
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
|
55
|
+
return typeof data.content === "string" ? data.content : "";
|
|
56
|
+
},
|
|
57
|
+
[fileVersions, projectId],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const writeProjectFile = useCallback(
|
|
61
|
+
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
|
62
|
+
if (!projectId) throw new Error("No active project");
|
|
63
|
+
const writeProjectId = projectId;
|
|
64
|
+
let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
|
|
65
|
+
if (expectedVersion === undefined) {
|
|
66
|
+
const preflight = await fetch(
|
|
67
|
+
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
68
|
+
);
|
|
69
|
+
if (preflight.ok) {
|
|
70
|
+
const data = (await preflight.json()) as { content?: string; version?: string };
|
|
71
|
+
throw new StudioFileConflictError({
|
|
72
|
+
filePath: path,
|
|
73
|
+
currentVersion: data.version ?? preflight.headers.get("etag"),
|
|
74
|
+
currentContent: data.content ?? null,
|
|
75
|
+
attemptedContent: content,
|
|
76
|
+
});
|
|
77
|
+
} else if (preflight.status === 404) {
|
|
78
|
+
expectedVersion = null;
|
|
79
|
+
} else {
|
|
80
|
+
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// fallow-ignore-next-line complexity
|
|
84
|
+
await retryStudioSave(async () => {
|
|
85
|
+
// Each request gets its own receipt identity. If a committed request loses its response,
|
|
86
|
+
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
|
87
|
+
let response: Response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetch(
|
|
90
|
+
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
|
91
|
+
{
|
|
92
|
+
method: "PUT",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "text/plain",
|
|
95
|
+
...studioWriteHeaders(),
|
|
96
|
+
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
|
97
|
+
},
|
|
98
|
+
body: content,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
|
|
103
|
+
cause: error,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (response.status === 409) {
|
|
107
|
+
const conflict = (await response.json().catch(() => null)) as {
|
|
108
|
+
currentVersion?: string | null;
|
|
109
|
+
currentContent?: string | null;
|
|
110
|
+
} | null;
|
|
111
|
+
const currentVersion = conflict?.currentVersion ?? null;
|
|
112
|
+
if (currentVersion && conflict?.currentContent === content) {
|
|
113
|
+
fileVersions.set(path, currentVersion);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
throw new StudioFileConflictError({
|
|
117
|
+
filePath: path,
|
|
118
|
+
currentVersion,
|
|
119
|
+
currentContent: conflict?.currentContent ?? null,
|
|
120
|
+
attemptedContent: content,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
|
124
|
+
const result = (await response.json()) as { version?: string };
|
|
125
|
+
const version = result.version ?? response.headers.get("etag");
|
|
126
|
+
if (!version)
|
|
127
|
+
throw new Error(`Save response for ${path} did not include a content version`);
|
|
128
|
+
fileVersions.set(path, version);
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
[fileVersions, projectId],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return useMemo(
|
|
135
|
+
() => ({
|
|
136
|
+
fileVersions,
|
|
137
|
+
readProjectFile,
|
|
138
|
+
writeProjectFile,
|
|
139
|
+
readOptionalProjectFile,
|
|
140
|
+
observeProjectFileVersion,
|
|
141
|
+
}),
|
|
142
|
+
[
|
|
143
|
+
fileVersions,
|
|
144
|
+
readProjectFile,
|
|
145
|
+
writeProjectFile,
|
|
146
|
+
readOptionalProjectFile,
|
|
147
|
+
observeProjectFileVersion,
|
|
148
|
+
],
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -16,19 +16,29 @@ afterEach(() => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
19
|
+
type FileDropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineFileDrop"];
|
|
20
|
+
type CompositionDropFn = ReturnType<
|
|
21
|
+
typeof useTimelineAssetDropOps
|
|
22
|
+
>["handleTimelineCompositionDrop"];
|
|
19
23
|
|
|
20
24
|
function renderDropHook(
|
|
21
25
|
sourceContent: string,
|
|
22
26
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
23
27
|
timelineElements: TimelineElement[] = [],
|
|
28
|
+
checkEditable?: (targets: readonly TimelineElement[]) => boolean,
|
|
29
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]> = vi
|
|
30
|
+
.fn()
|
|
31
|
+
.mockResolvedValue([]),
|
|
24
32
|
) {
|
|
25
33
|
vi.stubGlobal(
|
|
26
34
|
"fetch",
|
|
27
35
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ content: sourceContent }) }),
|
|
28
36
|
);
|
|
29
37
|
let drop: DropFn | null = null;
|
|
38
|
+
let fileDrop: FileDropFn | null = null;
|
|
39
|
+
let compositionDrop: CompositionDropFn | null = null;
|
|
30
40
|
function Harness() {
|
|
31
|
-
const
|
|
41
|
+
const handlers = useTimelineAssetDropOps({
|
|
32
42
|
projectIdRef: { current: "project" },
|
|
33
43
|
activeCompPath: "index.html",
|
|
34
44
|
timelineElements,
|
|
@@ -36,13 +46,19 @@ function renderDropHook(
|
|
|
36
46
|
writeProjectFile,
|
|
37
47
|
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
38
48
|
reloadPreview: vi.fn(),
|
|
39
|
-
uploadProjectFiles
|
|
49
|
+
uploadProjectFiles,
|
|
50
|
+
checkEditable,
|
|
40
51
|
});
|
|
41
|
-
drop = handleTimelineAssetDrop;
|
|
52
|
+
drop = handlers.handleTimelineAssetDrop;
|
|
53
|
+
fileDrop = handlers.handleTimelineFileDrop;
|
|
54
|
+
compositionDrop = handlers.handleTimelineCompositionDrop;
|
|
42
55
|
return null;
|
|
43
56
|
}
|
|
44
57
|
mountReactHarness(<Harness />);
|
|
45
|
-
return () => drop
|
|
58
|
+
return Object.assign(() => drop!, {
|
|
59
|
+
file: () => fileDrop!,
|
|
60
|
+
composition: () => compositionDrop!,
|
|
61
|
+
});
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
@@ -124,4 +140,46 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
124
140
|
expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
|
|
125
141
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
126
142
|
});
|
|
143
|
+
|
|
144
|
+
it("refuses an asset drop before reading or writing when editing is blocked", async () => {
|
|
145
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
146
|
+
const checkEditable = vi.fn(() => false);
|
|
147
|
+
const getDrop = renderDropHook(
|
|
148
|
+
'<main data-composition-id="scene" data-duration="10"></main>',
|
|
149
|
+
writeProjectFile,
|
|
150
|
+
[],
|
|
151
|
+
checkEditable,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
159
|
+
expect(checkEditable).toHaveBeenCalledWith([
|
|
160
|
+
expect.objectContaining({ sourceFile: "index.html", start: 1, track: 0 }),
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses file and composition drops before their writes when editing is blocked", async () => {
|
|
165
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
166
|
+
const uploadProjectFiles = vi.fn().mockResolvedValue(["clip.mp4"]);
|
|
167
|
+
const getDrop = renderDropHook(
|
|
168
|
+
'<main data-composition-id="scene" data-duration="10"></main>',
|
|
169
|
+
writeProjectFile,
|
|
170
|
+
[],
|
|
171
|
+
() => false,
|
|
172
|
+
uploadProjectFiles,
|
|
173
|
+
);
|
|
174
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await getDrop.file()([new File(["clip"], "clip.mp4")], { start: 1, track: 0 });
|
|
178
|
+
await getDrop.composition()("scene.html", { start: 1, track: 0 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(uploadProjectFiles).not.toHaveBeenCalled();
|
|
182
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
183
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
127
185
|
});
|
|
@@ -38,6 +38,20 @@ function fileDropPlacement(
|
|
|
38
38
|
return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function timelineDropTarget(
|
|
42
|
+
sourceFile: string,
|
|
43
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
44
|
+
): TimelineElement {
|
|
45
|
+
return {
|
|
46
|
+
id: "timeline-drop",
|
|
47
|
+
tag: "div",
|
|
48
|
+
start: placement.start,
|
|
49
|
+
duration: 0,
|
|
50
|
+
track: placement.track,
|
|
51
|
+
sourceFile,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
interface UseTimelineAssetDropOpsOptions {
|
|
42
56
|
projectIdRef: MutableRefObject<string | null>;
|
|
43
57
|
activeCompPath: string | null;
|
|
@@ -50,6 +64,7 @@ interface UseTimelineAssetDropOpsOptions {
|
|
|
50
64
|
isRecordingRef?: RefObject<boolean>;
|
|
51
65
|
forceReloadSdkSession?: () => void;
|
|
52
66
|
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
67
|
+
checkEditable?: (targets: readonly TimelineElement[]) => boolean;
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
export function useTimelineAssetDropOps({
|
|
@@ -64,6 +79,7 @@ export function useTimelineAssetDropOps({
|
|
|
64
79
|
isRecordingRef,
|
|
65
80
|
forceReloadSdkSession,
|
|
66
81
|
observeProjectFileVersion,
|
|
82
|
+
checkEditable,
|
|
67
83
|
}: UseTimelineAssetDropOpsOptions) {
|
|
68
84
|
// fallow-ignore-next-line complexity
|
|
69
85
|
const dropAssetAt = useCallback(
|
|
@@ -77,6 +93,10 @@ export function useTimelineAssetDropOps({
|
|
|
77
93
|
showToast("Cannot edit timeline while recording", "error");
|
|
78
94
|
return undefined;
|
|
79
95
|
}
|
|
96
|
+
const targetPath = activeCompPath || "index.html";
|
|
97
|
+
if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
80
100
|
const pid = projectIdRef.current;
|
|
81
101
|
if (!pid) throw new Error("No active project");
|
|
82
102
|
|
|
@@ -86,7 +106,6 @@ export function useTimelineAssetDropOps({
|
|
|
86
106
|
return undefined;
|
|
87
107
|
}
|
|
88
108
|
|
|
89
|
-
const targetPath = activeCompPath || "index.html";
|
|
90
109
|
try {
|
|
91
110
|
const originalContent = await readFileContent(pid, targetPath);
|
|
92
111
|
|
|
@@ -172,6 +191,7 @@ export function useTimelineAssetDropOps({
|
|
|
172
191
|
reloadPreview,
|
|
173
192
|
isRecordingRef,
|
|
174
193
|
forceReloadSdkSession,
|
|
194
|
+
checkEditable,
|
|
175
195
|
],
|
|
176
196
|
);
|
|
177
197
|
|
|
@@ -190,6 +210,11 @@ export function useTimelineAssetDropOps({
|
|
|
190
210
|
showToast("Cannot edit timeline while recording", "error");
|
|
191
211
|
return;
|
|
192
212
|
}
|
|
213
|
+
const targetPath = activeCompPath || "index.html";
|
|
214
|
+
const initialPlacement = placement ?? { start: 0, track: 0 };
|
|
215
|
+
if (checkEditable && !checkEditable([timelineDropTarget(targetPath, initialPlacement)])) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
193
218
|
const pid = projectIdRef.current;
|
|
194
219
|
if (!pid) return;
|
|
195
220
|
const uploaded = await uploadProjectFiles(files);
|
|
@@ -215,7 +240,15 @@ export function useTimelineAssetDropOps({
|
|
|
215
240
|
if (index === 0) landedTrack = track;
|
|
216
241
|
}
|
|
217
242
|
},
|
|
218
|
-
[
|
|
243
|
+
[
|
|
244
|
+
activeCompPath,
|
|
245
|
+
checkEditable,
|
|
246
|
+
dropAssetAt,
|
|
247
|
+
projectIdRef,
|
|
248
|
+
uploadProjectFiles,
|
|
249
|
+
isRecordingRef,
|
|
250
|
+
showToast,
|
|
251
|
+
],
|
|
219
252
|
);
|
|
220
253
|
|
|
221
254
|
const handleTimelineCompositionDrop = useCallback(
|
|
@@ -224,9 +257,12 @@ export function useTimelineAssetDropOps({
|
|
|
224
257
|
showToast("Cannot edit timeline while recording", "error");
|
|
225
258
|
return;
|
|
226
259
|
}
|
|
260
|
+
const targetPath = activeCompPath || "index.html";
|
|
261
|
+
if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
227
264
|
const pid = projectIdRef.current;
|
|
228
265
|
if (!pid) throw new Error("No active project");
|
|
229
|
-
const targetPath = activeCompPath || "index.html";
|
|
230
266
|
try {
|
|
231
267
|
await commitTimelineCompositionInsertion({
|
|
232
268
|
projectId: pid,
|
|
@@ -251,6 +287,7 @@ export function useTimelineAssetDropOps({
|
|
|
251
287
|
},
|
|
252
288
|
[
|
|
253
289
|
activeCompPath,
|
|
290
|
+
checkEditable,
|
|
254
291
|
forceReloadSdkSession,
|
|
255
292
|
isRecordingRef,
|
|
256
293
|
observeProjectFileVersion,
|