@hyperframes/studio 0.6.0-alpha.9 → 0.6.1
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-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
2
|
+
import type { EditingFile } from "../utils/studioHelpers";
|
|
3
|
+
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
|
|
4
|
+
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
6
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
7
|
+
|
|
8
|
+
// ── Types ──
|
|
9
|
+
|
|
10
|
+
interface RecordEditInput {
|
|
11
|
+
label: string;
|
|
12
|
+
kind: EditHistoryKind;
|
|
13
|
+
coalesceKey?: string;
|
|
14
|
+
files: Record<string, { before: string; after: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UseFileManagerOptions {
|
|
18
|
+
projectId: string | null;
|
|
19
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
20
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
21
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
22
|
+
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Hook ──
|
|
26
|
+
|
|
27
|
+
export function useFileManager({
|
|
28
|
+
projectId,
|
|
29
|
+
showToast,
|
|
30
|
+
recordEdit,
|
|
31
|
+
domEditSaveTimestampRef,
|
|
32
|
+
setRefreshKey,
|
|
33
|
+
}: UseFileManagerOptions) {
|
|
34
|
+
// ── State ──
|
|
35
|
+
|
|
36
|
+
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
37
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
38
|
+
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
39
|
+
|
|
40
|
+
// ── Refs ──
|
|
41
|
+
|
|
42
|
+
const editingPathRef = useRef(editingFile?.path);
|
|
43
|
+
editingPathRef.current = editingFile?.path;
|
|
44
|
+
|
|
45
|
+
const projectIdRef = useRef(projectId);
|
|
46
|
+
projectIdRef.current = projectId;
|
|
47
|
+
|
|
48
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
49
|
+
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
|
+
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
51
|
+
|
|
52
|
+
// ── Load file tree when projectId changes ──
|
|
53
|
+
|
|
54
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!projectId) return;
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
fetch(`/api/projects/${projectId}`)
|
|
59
|
+
.then((r) => r.json())
|
|
60
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
61
|
+
if (!cancelled && data.files) setFileTree(data.files);
|
|
62
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
63
|
+
})
|
|
64
|
+
.catch(() => {
|
|
65
|
+
if (!cancelled) setProjectDir(null);
|
|
66
|
+
});
|
|
67
|
+
return () => {
|
|
68
|
+
cancelled = true;
|
|
69
|
+
};
|
|
70
|
+
}, [projectId]);
|
|
71
|
+
|
|
72
|
+
// ── Core file I/O ──
|
|
73
|
+
|
|
74
|
+
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
75
|
+
const pid = projectIdRef.current;
|
|
76
|
+
if (!pid) throw new Error("No active project");
|
|
77
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
78
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
79
|
+
const data = (await response.json()) as { content?: string };
|
|
80
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
81
|
+
return data.content;
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
|
85
|
+
const pid = projectIdRef.current;
|
|
86
|
+
if (!pid) throw new Error("No active project");
|
|
87
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
headers: { "Content-Type": "text/plain" },
|
|
90
|
+
body: content,
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
|
93
|
+
if (editingPathRef.current === path) {
|
|
94
|
+
setEditingFile({ path, content });
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
99
|
+
const pid = projectIdRef.current;
|
|
100
|
+
if (!pid) throw new Error("No active project");
|
|
101
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
102
|
+
if (response.status === 404) return "";
|
|
103
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
104
|
+
const data = (await response.json()) as { content?: string };
|
|
105
|
+
return typeof data.content === "string" ? data.content : "";
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
// ── File select ──
|
|
109
|
+
|
|
110
|
+
const handleFileSelect = useCallback((path: string) => {
|
|
111
|
+
const pid = projectIdRef.current;
|
|
112
|
+
if (!pid) return;
|
|
113
|
+
// Skip fetching binary content for media files — just set the path for preview
|
|
114
|
+
if (isMediaFile(path)) {
|
|
115
|
+
setEditingFile({ path, content: null });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
|
119
|
+
.then((r) => r.json())
|
|
120
|
+
.then((data: { content?: string }) => {
|
|
121
|
+
if (data.content != null) {
|
|
122
|
+
setEditingFile({ path, content: data.content });
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.catch(() => {});
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
// ── Content change (debounced save) ──
|
|
129
|
+
|
|
130
|
+
const handleContentChange = useCallback(
|
|
131
|
+
(content: string) => {
|
|
132
|
+
const pid = projectIdRef.current;
|
|
133
|
+
if (!pid) return;
|
|
134
|
+
const path = editingPathRef.current;
|
|
135
|
+
if (!path) return;
|
|
136
|
+
|
|
137
|
+
// Debounce the server write (600ms)
|
|
138
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
139
|
+
saveTimerRef.current = setTimeout(() => {
|
|
140
|
+
// Suppress the file-change watcher echo — the save callback triggers
|
|
141
|
+
// its own refresh, so a second one from the watcher causes a double-reload
|
|
142
|
+
// race that can leave the player in a non-playable state.
|
|
143
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
144
|
+
saveProjectFilesWithHistory({
|
|
145
|
+
projectId: pid,
|
|
146
|
+
label: "Edit source",
|
|
147
|
+
kind: "source",
|
|
148
|
+
coalesceKey: `source:${path}`,
|
|
149
|
+
files: { [path]: content },
|
|
150
|
+
readFile: readProjectFile,
|
|
151
|
+
writeFile: writeProjectFile,
|
|
152
|
+
recordEdit,
|
|
153
|
+
})
|
|
154
|
+
.then(() => {
|
|
155
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
156
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
157
|
+
})
|
|
158
|
+
.catch(() => {});
|
|
159
|
+
}, 600);
|
|
160
|
+
},
|
|
161
|
+
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ── File tree refresh ──
|
|
165
|
+
|
|
166
|
+
const refreshFileTree = useCallback(async () => {
|
|
167
|
+
const pid = projectIdRef.current;
|
|
168
|
+
if (!pid) return;
|
|
169
|
+
const res = await fetch(`/api/projects/${pid}`);
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
if (data.files) setFileTree(data.files);
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
// ── Upload ──
|
|
175
|
+
|
|
176
|
+
const uploadProjectFiles = useCallback(
|
|
177
|
+
async (files: Iterable<File>, dir?: string): Promise<string[]> => {
|
|
178
|
+
const pid = projectIdRef.current;
|
|
179
|
+
const fileList = Array.from(files);
|
|
180
|
+
if (!pid || fileList.length === 0) return [];
|
|
181
|
+
|
|
182
|
+
const formData = new FormData();
|
|
183
|
+
for (const file of fileList) {
|
|
184
|
+
formData.append("file", file);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
body: formData,
|
|
192
|
+
});
|
|
193
|
+
if (res.ok) {
|
|
194
|
+
const data = await res.json();
|
|
195
|
+
if (data.skipped?.length) {
|
|
196
|
+
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
197
|
+
}
|
|
198
|
+
if (data.invalid?.length) {
|
|
199
|
+
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
|
|
200
|
+
showToast(`Unsupported media skipped: ${names}`);
|
|
201
|
+
}
|
|
202
|
+
await refreshFileTree();
|
|
203
|
+
setRefreshKey((k) => k + 1);
|
|
204
|
+
return Array.isArray(data.files) ? data.files : [];
|
|
205
|
+
} else if (res.status === 413) {
|
|
206
|
+
showToast("Upload rejected: payload too large");
|
|
207
|
+
} else {
|
|
208
|
+
showToast(`Upload failed (${res.status})`);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
showToast("Upload failed: network error");
|
|
212
|
+
}
|
|
213
|
+
return [];
|
|
214
|
+
},
|
|
215
|
+
[refreshFileTree, setRefreshKey, showToast],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// ── File management handlers ──
|
|
219
|
+
|
|
220
|
+
const handleCreateFile = useCallback(
|
|
221
|
+
async (path: string) => {
|
|
222
|
+
const pid = projectIdRef.current;
|
|
223
|
+
if (!pid) return;
|
|
224
|
+
let content = "";
|
|
225
|
+
if (path.endsWith(".html")) {
|
|
226
|
+
content =
|
|
227
|
+
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
|
228
|
+
}
|
|
229
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { "Content-Type": "text/plain" },
|
|
232
|
+
body: content,
|
|
233
|
+
});
|
|
234
|
+
if (res.ok) {
|
|
235
|
+
await refreshFileTree();
|
|
236
|
+
handleFileSelect(path);
|
|
237
|
+
} else {
|
|
238
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
239
|
+
console.error(`Create file failed: ${err.error}`);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
[refreshFileTree, handleFileSelect],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const handleCreateFolder = useCallback(
|
|
246
|
+
async (path: string) => {
|
|
247
|
+
const pid = projectIdRef.current;
|
|
248
|
+
if (!pid) return;
|
|
249
|
+
// Create a .gitkeep inside the folder so it appears in the tree
|
|
250
|
+
const res = await fetch(
|
|
251
|
+
`/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
|
252
|
+
{
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: { "Content-Type": "text/plain" },
|
|
255
|
+
body: "",
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
if (res.ok) {
|
|
259
|
+
await refreshFileTree();
|
|
260
|
+
} else {
|
|
261
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
262
|
+
console.error(`Create folder failed: ${err.error}`);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
[refreshFileTree],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const handleDeleteFile = useCallback(
|
|
269
|
+
async (path: string) => {
|
|
270
|
+
const pid = projectIdRef.current;
|
|
271
|
+
if (!pid) return;
|
|
272
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
273
|
+
method: "DELETE",
|
|
274
|
+
});
|
|
275
|
+
if (res.ok) {
|
|
276
|
+
if (editingPathRef.current === path) setEditingFile(null);
|
|
277
|
+
await refreshFileTree();
|
|
278
|
+
} else {
|
|
279
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
280
|
+
console.error(`Delete failed: ${err.error}`);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
[refreshFileTree],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const handleRenameFile = useCallback(
|
|
287
|
+
async (oldPath: string, newPath: string) => {
|
|
288
|
+
const pid = projectIdRef.current;
|
|
289
|
+
if (!pid) return;
|
|
290
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
|
|
291
|
+
method: "PATCH",
|
|
292
|
+
headers: { "Content-Type": "application/json" },
|
|
293
|
+
body: JSON.stringify({ newPath }),
|
|
294
|
+
});
|
|
295
|
+
if (res.ok) {
|
|
296
|
+
if (editingPathRef.current === oldPath) {
|
|
297
|
+
handleFileSelect(newPath);
|
|
298
|
+
}
|
|
299
|
+
await refreshFileTree();
|
|
300
|
+
// Refresh preview — references in compositions may have been updated
|
|
301
|
+
setRefreshKey((k) => k + 1);
|
|
302
|
+
} else {
|
|
303
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
304
|
+
console.error(`Rename failed: ${err.error}`);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
[refreshFileTree, handleFileSelect, setRefreshKey],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const handleDuplicateFile = useCallback(
|
|
311
|
+
async (path: string) => {
|
|
312
|
+
const pid = projectIdRef.current;
|
|
313
|
+
if (!pid) return;
|
|
314
|
+
const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: { "Content-Type": "application/json" },
|
|
317
|
+
body: JSON.stringify({ path }),
|
|
318
|
+
});
|
|
319
|
+
if (res.ok) {
|
|
320
|
+
const data = await res.json();
|
|
321
|
+
await refreshFileTree();
|
|
322
|
+
if (data.path) handleFileSelect(data.path);
|
|
323
|
+
} else {
|
|
324
|
+
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
325
|
+
console.error(`Duplicate failed: ${err.error}`);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
[refreshFileTree, handleFileSelect],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const handleMoveFile = handleRenameFile;
|
|
332
|
+
|
|
333
|
+
const handleImportFiles = useCallback(
|
|
334
|
+
async (files: FileList | File[], dir?: string) => {
|
|
335
|
+
return uploadProjectFiles(Array.from(files), dir);
|
|
336
|
+
},
|
|
337
|
+
[uploadProjectFiles],
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const handleImportFonts = useCallback(
|
|
341
|
+
async (files: FileList | File[]): Promise<ImportedFontAsset[]> => {
|
|
342
|
+
const uploaded = await uploadProjectFiles(
|
|
343
|
+
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
|
344
|
+
"assets/fonts",
|
|
345
|
+
);
|
|
346
|
+
const pid = projectIdRef.current;
|
|
347
|
+
const imported = uploaded
|
|
348
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
349
|
+
.map((asset) => ({
|
|
350
|
+
family: fontFamilyFromAssetPath(asset),
|
|
351
|
+
path: asset,
|
|
352
|
+
url: `/api/projects/${pid}/preview/${asset}`,
|
|
353
|
+
}));
|
|
354
|
+
importedFontAssetsRef.current = [
|
|
355
|
+
...imported,
|
|
356
|
+
...importedFontAssetsRef.current.filter(
|
|
357
|
+
(existing) =>
|
|
358
|
+
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
|
359
|
+
),
|
|
360
|
+
];
|
|
361
|
+
return imported;
|
|
362
|
+
},
|
|
363
|
+
[uploadProjectFiles],
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// ── Derived state ──
|
|
367
|
+
|
|
368
|
+
const compositions = useMemo(
|
|
369
|
+
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
|
|
370
|
+
[fileTree],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const assets = useMemo(
|
|
374
|
+
() =>
|
|
375
|
+
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
376
|
+
[fileTree],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const fontAssets = useMemo<ImportedFontAsset[]>(
|
|
380
|
+
() =>
|
|
381
|
+
assets
|
|
382
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
383
|
+
.map((asset) => ({
|
|
384
|
+
family: fontFamilyFromAssetPath(asset),
|
|
385
|
+
path: asset,
|
|
386
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
387
|
+
})),
|
|
388
|
+
[assets, projectId],
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// ── Return ──
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
// State
|
|
395
|
+
editingFile,
|
|
396
|
+
setEditingFile,
|
|
397
|
+
projectDir,
|
|
398
|
+
fileTree,
|
|
399
|
+
setFileTree,
|
|
400
|
+
|
|
401
|
+
// Refs
|
|
402
|
+
editingPathRef,
|
|
403
|
+
projectIdRef,
|
|
404
|
+
saveTimerRef,
|
|
405
|
+
importedFontAssetsRef,
|
|
406
|
+
|
|
407
|
+
// Core I/O
|
|
408
|
+
readProjectFile,
|
|
409
|
+
writeProjectFile,
|
|
410
|
+
readOptionalProjectFile,
|
|
411
|
+
|
|
412
|
+
// Callbacks
|
|
413
|
+
handleFileSelect,
|
|
414
|
+
handleContentChange,
|
|
415
|
+
refreshFileTree,
|
|
416
|
+
uploadProjectFiles,
|
|
417
|
+
handleCreateFile,
|
|
418
|
+
handleCreateFolder,
|
|
419
|
+
handleDeleteFile,
|
|
420
|
+
handleRenameFile,
|
|
421
|
+
handleDuplicateFile,
|
|
422
|
+
handleMoveFile,
|
|
423
|
+
handleImportFiles,
|
|
424
|
+
handleImportFonts,
|
|
425
|
+
|
|
426
|
+
// Derived
|
|
427
|
+
compositions,
|
|
428
|
+
assets,
|
|
429
|
+
fontAssets,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, useCallback, type MouseEvent } from "react";
|
|
2
|
+
import { useMountEffect } from "./useMountEffect";
|
|
3
|
+
import { liveTime, usePlayerStore } from "../player";
|
|
4
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
|
5
|
+
|
|
6
|
+
interface UseFrameCaptureParams {
|
|
7
|
+
projectId: string | null;
|
|
8
|
+
activeCompPath: string | null;
|
|
9
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
10
|
+
waitForPendingDomEditSaves: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useFrameCapture({
|
|
14
|
+
projectId,
|
|
15
|
+
activeCompPath,
|
|
16
|
+
showToast,
|
|
17
|
+
waitForPendingDomEditSaves,
|
|
18
|
+
}: UseFrameCaptureParams) {
|
|
19
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
20
|
+
|
|
21
|
+
useMountEffect(() => {
|
|
22
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
23
|
+
return liveTime.subscribe(setCaptureFrameTime);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const refreshCaptureFrameTime = useCallback(() => {
|
|
27
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const handleCaptureFrameClick = useCallback(
|
|
31
|
+
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
32
|
+
if (!projectId) return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const time = usePlayerStore.getState().currentTime;
|
|
35
|
+
setCaptureFrameTime(time);
|
|
36
|
+
await waitForPendingDomEditSaves();
|
|
37
|
+
const href = buildFrameCaptureUrl({
|
|
38
|
+
projectId,
|
|
39
|
+
compositionPath: activeCompPath,
|
|
40
|
+
currentTime: time,
|
|
41
|
+
});
|
|
42
|
+
const filename = buildFrameCaptureFilename(activeCompPath, time);
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(href, { cache: "no-store" });
|
|
45
|
+
if (!response.ok) throw new Error(`Capture failed (${response.status})`);
|
|
46
|
+
const blob = await response.blob();
|
|
47
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
48
|
+
const link = document.createElement("a");
|
|
49
|
+
link.href = blobUrl;
|
|
50
|
+
link.download = filename;
|
|
51
|
+
document.body.appendChild(link);
|
|
52
|
+
link.click();
|
|
53
|
+
link.remove();
|
|
54
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
showToast(err instanceof Error ? err.message : "Capture failed");
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const captureFrameHref = projectId
|
|
63
|
+
? buildFrameCaptureUrl({
|
|
64
|
+
projectId,
|
|
65
|
+
compositionPath: activeCompPath,
|
|
66
|
+
currentTime: captureFrameTime,
|
|
67
|
+
})
|
|
68
|
+
: "#";
|
|
69
|
+
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
captureFrameHref,
|
|
73
|
+
captureFrameFilename,
|
|
74
|
+
handleCaptureFrameClick,
|
|
75
|
+
refreshCaptureFrameTime,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import type { LintFinding } from "../components/LintModal";
|
|
3
|
+
|
|
4
|
+
export function useLintModal(projectId: string | null) {
|
|
5
|
+
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
|
6
|
+
const [linting, setLinting] = useState(false);
|
|
7
|
+
|
|
8
|
+
const handleLint = useCallback(async () => {
|
|
9
|
+
if (!projectId) return;
|
|
10
|
+
setLinting(true);
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(`/api/projects/${projectId}/lint`);
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
setLintModal(
|
|
15
|
+
(data.findings ?? []).map(
|
|
16
|
+
(f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
|
|
17
|
+
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
|
|
18
|
+
message: f.message ?? "",
|
|
19
|
+
file: f.file,
|
|
20
|
+
fixHint: f.fixHint,
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
26
|
+
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
|
|
27
|
+
} finally {
|
|
28
|
+
setLinting(false);
|
|
29
|
+
}
|
|
30
|
+
}, [projectId]);
|
|
31
|
+
|
|
32
|
+
const closeLintModal = useCallback(() => setLintModal(null), []);
|
|
33
|
+
|
|
34
|
+
return { lintModal, linting, handleLint, closeLintModal };
|
|
35
|
+
}
|