@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11
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/index-Bl4Deziq.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +494 -185
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
package/src/App.tsx
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useRef,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
type MouseEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
2
10
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
3
11
|
import { NLELayout } from "./components/nle/NLELayout";
|
|
4
12
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
5
13
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
6
14
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
7
15
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
8
|
-
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
|
|
16
|
+
import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
|
|
9
17
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
|
10
18
|
import type { TimelineElement } from "./player";
|
|
11
19
|
import { LintModal } from "./components/LintModal";
|
|
12
20
|
import type { LintFinding } from "./components/LintModal";
|
|
13
21
|
import { MediaPreview } from "./components/MediaPreview";
|
|
22
|
+
import { RotateCcw, RotateCw } from "./icons/SystemIcons";
|
|
14
23
|
import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
|
|
15
24
|
import {
|
|
16
25
|
buildTimelineAssetId,
|
|
@@ -28,6 +37,8 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
28
37
|
import { useCaptionStore } from "./captions/store";
|
|
29
38
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
30
39
|
import { parseCaptionComposition } from "./captions/parser";
|
|
40
|
+
import { copyTextToClipboard } from "./utils/clipboard";
|
|
41
|
+
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
31
42
|
import {
|
|
32
43
|
applyPatchByTarget,
|
|
33
44
|
readAttributeByTarget,
|
|
@@ -49,6 +60,8 @@ import {
|
|
|
49
60
|
setTimelineEditorHintDismissed,
|
|
50
61
|
shouldHandleTimelineToggleHotkey,
|
|
51
62
|
} from "./utils/timelineDiscovery";
|
|
63
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
64
|
+
import { Camera } from "./icons/SystemIcons";
|
|
52
65
|
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
53
66
|
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
54
67
|
import {
|
|
@@ -73,6 +86,7 @@ import {
|
|
|
73
86
|
type DomEditTextField,
|
|
74
87
|
type DomEditSelection,
|
|
75
88
|
} from "./components/editor/domEditing";
|
|
89
|
+
import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
|
|
76
90
|
|
|
77
91
|
interface EditingFile {
|
|
78
92
|
path: string;
|
|
@@ -166,6 +180,23 @@ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): stri
|
|
|
166
180
|
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
167
181
|
}
|
|
168
182
|
|
|
183
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
184
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
188
|
+
const trimmedSource = sourceFile.trim();
|
|
189
|
+
if (!trimmedSource) return undefined;
|
|
190
|
+
|
|
191
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
192
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
193
|
+
|
|
194
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
195
|
+
if (!normalizedRoot) return undefined;
|
|
196
|
+
|
|
197
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
169
200
|
function ensureImportedFontFace(
|
|
170
201
|
html: string,
|
|
171
202
|
asset: ImportedFontAsset,
|
|
@@ -239,6 +270,29 @@ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
|
239
270
|
return null;
|
|
240
271
|
}
|
|
241
272
|
|
|
273
|
+
function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
274
|
+
const el = getEventTargetElement(target);
|
|
275
|
+
if (!el) return false;
|
|
276
|
+
return Boolean(
|
|
277
|
+
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
282
|
+
const isMac =
|
|
283
|
+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
284
|
+
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
285
|
+
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function getDomEditCoalesceKey(
|
|
289
|
+
selection: Pick<DomEditSelection, "id" | "selector" | "sourceFile">,
|
|
290
|
+
action: "move" | "resize",
|
|
291
|
+
): string {
|
|
292
|
+
const target = selection.id || selection.selector || "selection";
|
|
293
|
+
return `${action}:${selection.sourceFile || "index.html"}:${target}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
242
296
|
function findMatchingTimelineElementId(
|
|
243
297
|
selection: Pick<
|
|
244
298
|
DomEditSelection,
|
|
@@ -574,6 +628,7 @@ export function StudioApp() {
|
|
|
574
628
|
});
|
|
575
629
|
|
|
576
630
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
631
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
577
632
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
578
633
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
579
634
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -589,6 +644,7 @@ export function StudioApp() {
|
|
|
589
644
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
590
645
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
591
646
|
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
647
|
+
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
592
648
|
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
593
649
|
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
594
650
|
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
@@ -714,6 +770,7 @@ export function StudioApp() {
|
|
|
714
770
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
715
771
|
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
716
772
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
773
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
717
774
|
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
|
718
775
|
getTimelineEditorHintDismissed,
|
|
719
776
|
);
|
|
@@ -756,6 +813,29 @@ export function StudioApp() {
|
|
|
756
813
|
const toggleTimelineVisibility = useCallback(() => {
|
|
757
814
|
setTimelineVisible((visible) => !visible);
|
|
758
815
|
}, []);
|
|
816
|
+
const toggleLeftSidebar = useCallback(() => {
|
|
817
|
+
setLeftCollapsed((collapsed) => !collapsed);
|
|
818
|
+
}, []);
|
|
819
|
+
const refreshCaptureFrameTime = useCallback(() => {
|
|
820
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
821
|
+
}, []);
|
|
822
|
+
|
|
823
|
+
useMountEffect(() => {
|
|
824
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
825
|
+
return liveTime.subscribe(setCaptureFrameTime);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const captureFrameHref = projectId
|
|
829
|
+
? buildFrameCaptureUrl({
|
|
830
|
+
projectId,
|
|
831
|
+
compositionPath: activeCompPath,
|
|
832
|
+
currentTime: captureFrameTime,
|
|
833
|
+
})
|
|
834
|
+
: "#";
|
|
835
|
+
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
836
|
+
useMountEffect(() => () => {
|
|
837
|
+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
838
|
+
});
|
|
759
839
|
const dismissTimelineEditorHint = useCallback(() => {
|
|
760
840
|
setTimelineEditorHintState(true);
|
|
761
841
|
setTimelineEditorHintDismissed(true);
|
|
@@ -838,6 +918,8 @@ export function StudioApp() {
|
|
|
838
918
|
label={el.id || el.tag}
|
|
839
919
|
labelColor={style.label}
|
|
840
920
|
accentColor={style.clip}
|
|
921
|
+
selector={el.selector}
|
|
922
|
+
selectorIndex={el.selectorIndex}
|
|
841
923
|
seekTime={el.start}
|
|
842
924
|
duration={el.duration}
|
|
843
925
|
/>
|
|
@@ -852,13 +934,28 @@ export function StudioApp() {
|
|
|
852
934
|
|
|
853
935
|
// Audio clips — waveform visualization
|
|
854
936
|
if (el.tag === "audio") {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
937
|
+
const previewBase = `/api/projects/${pid}/preview/`;
|
|
938
|
+
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
|
|
939
|
+
const srcRelative = el.src
|
|
940
|
+
? previewIdx !== -1
|
|
941
|
+
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
|
|
942
|
+
: el.src.startsWith("http")
|
|
943
|
+
? null
|
|
944
|
+
: el.src
|
|
945
|
+
: null;
|
|
946
|
+
const audioUrl = srcRelative
|
|
947
|
+
? `/api/projects/${pid}/preview/${srcRelative}`
|
|
948
|
+
: (el.src ?? "");
|
|
949
|
+
const waveformUrl = srcRelative
|
|
950
|
+
? `/api/projects/${pid}/waveform/${srcRelative}`
|
|
951
|
+
: undefined;
|
|
860
952
|
return (
|
|
861
|
-
<AudioWaveform
|
|
953
|
+
<AudioWaveform
|
|
954
|
+
audioUrl={audioUrl}
|
|
955
|
+
waveformUrl={waveformUrl}
|
|
956
|
+
label={el.id || el.tag}
|
|
957
|
+
labelColor={style.label}
|
|
958
|
+
/>
|
|
862
959
|
);
|
|
863
960
|
}
|
|
864
961
|
|
|
@@ -883,6 +980,8 @@ export function StudioApp() {
|
|
|
883
980
|
label={el.id || el.tag}
|
|
884
981
|
labelColor={style.label}
|
|
885
982
|
accentColor={style.clip}
|
|
983
|
+
selector={el.selector}
|
|
984
|
+
selectorIndex={el.selectorIndex}
|
|
886
985
|
seekTime={el.start}
|
|
887
986
|
duration={el.duration}
|
|
888
987
|
/>
|
|
@@ -962,6 +1061,28 @@ export function StudioApp() {
|
|
|
962
1061
|
>
|
|
963
1062
|
+
|
|
964
1063
|
</button>
|
|
1064
|
+
<button
|
|
1065
|
+
type="button"
|
|
1066
|
+
onClick={toggleTimelineVisibility}
|
|
1067
|
+
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
|
1068
|
+
title={getTimelineToggleTitle(true)}
|
|
1069
|
+
aria-label="Hide timeline editor"
|
|
1070
|
+
>
|
|
1071
|
+
<svg
|
|
1072
|
+
width="14"
|
|
1073
|
+
height="14"
|
|
1074
|
+
viewBox="0 0 24 24"
|
|
1075
|
+
fill="none"
|
|
1076
|
+
stroke="currentColor"
|
|
1077
|
+
strokeWidth="1.8"
|
|
1078
|
+
strokeLinecap="round"
|
|
1079
|
+
strokeLinejoin="round"
|
|
1080
|
+
aria-hidden="true"
|
|
1081
|
+
>
|
|
1082
|
+
<path d="M5 7h14" />
|
|
1083
|
+
<path d="m8 11 4 4 4-4" />
|
|
1084
|
+
</svg>
|
|
1085
|
+
</button>
|
|
965
1086
|
</div>
|
|
966
1087
|
</div>
|
|
967
1088
|
</div>
|
|
@@ -1014,10 +1135,13 @@ export function StudioApp() {
|
|
|
1014
1135
|
let cancelled = false;
|
|
1015
1136
|
fetch(`/api/projects/${projectId}`)
|
|
1016
1137
|
.then((r) => r.json())
|
|
1017
|
-
.then((data: { files?: string[] }) => {
|
|
1138
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
1018
1139
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1140
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
1019
1141
|
})
|
|
1020
|
-
.catch(() => {
|
|
1142
|
+
.catch(() => {
|
|
1143
|
+
if (!cancelled) setProjectDir(null);
|
|
1144
|
+
});
|
|
1021
1145
|
return () => {
|
|
1022
1146
|
cancelled = true;
|
|
1023
1147
|
};
|
|
@@ -1045,29 +1169,62 @@ export function StudioApp() {
|
|
|
1045
1169
|
|
|
1046
1170
|
const editingPathRef = useRef(editingFile?.path);
|
|
1047
1171
|
editingPathRef.current = editingFile?.path;
|
|
1172
|
+
const editHistory = usePersistentEditHistory({ projectId });
|
|
1048
1173
|
|
|
1049
|
-
const
|
|
1174
|
+
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1050
1175
|
const pid = projectIdRef.current;
|
|
1051
|
-
if (!pid)
|
|
1052
|
-
const
|
|
1053
|
-
if (!path)
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1176
|
+
if (!pid) throw new Error("No active project");
|
|
1177
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1178
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1179
|
+
const data = (await response.json()) as { content?: string };
|
|
1180
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
1181
|
+
return data.content;
|
|
1182
|
+
}, []);
|
|
1183
|
+
|
|
1184
|
+
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
|
1185
|
+
const pid = projectIdRef.current;
|
|
1186
|
+
if (!pid) throw new Error("No active project");
|
|
1187
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
1188
|
+
method: "PUT",
|
|
1189
|
+
headers: { "Content-Type": "text/plain" },
|
|
1190
|
+
body: content,
|
|
1191
|
+
});
|
|
1192
|
+
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
|
1193
|
+
if (editingPathRef.current === path) {
|
|
1194
|
+
setEditingFile({ path, content });
|
|
1195
|
+
}
|
|
1069
1196
|
}, []);
|
|
1070
1197
|
|
|
1198
|
+
const handleContentChange = useCallback(
|
|
1199
|
+
(content: string) => {
|
|
1200
|
+
const pid = projectIdRef.current;
|
|
1201
|
+
if (!pid) return;
|
|
1202
|
+
const path = editingPathRef.current;
|
|
1203
|
+
if (!path) return;
|
|
1204
|
+
|
|
1205
|
+
// Debounce the server write (600ms)
|
|
1206
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
1207
|
+
saveTimerRef.current = setTimeout(() => {
|
|
1208
|
+
saveProjectFilesWithHistory({
|
|
1209
|
+
projectId: pid,
|
|
1210
|
+
label: "Edit source",
|
|
1211
|
+
kind: "source",
|
|
1212
|
+
coalesceKey: `source:${path}`,
|
|
1213
|
+
files: { [path]: content },
|
|
1214
|
+
readFile: readProjectFile,
|
|
1215
|
+
writeFile: writeProjectFile,
|
|
1216
|
+
recordEdit: editHistory.recordEdit,
|
|
1217
|
+
})
|
|
1218
|
+
.then(() => {
|
|
1219
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1220
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
1221
|
+
})
|
|
1222
|
+
.catch(() => {});
|
|
1223
|
+
}, 600);
|
|
1224
|
+
},
|
|
1225
|
+
[editHistory.recordEdit, readProjectFile, writeProjectFile],
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1071
1228
|
const handleTimelineElementMove = useCallback(
|
|
1072
1229
|
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
1073
1230
|
const pid = projectIdRef.current;
|
|
@@ -1146,25 +1303,19 @@ export function StudioApp() {
|
|
|
1146
1303
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1147
1304
|
}
|
|
1148
1305
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
if (editingPathRef.current === targetPath) {
|
|
1162
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1163
|
-
}
|
|
1306
|
+
await saveProjectFilesWithHistory({
|
|
1307
|
+
projectId: pid,
|
|
1308
|
+
label: "Move timeline clip",
|
|
1309
|
+
kind: "timeline",
|
|
1310
|
+
files: { [targetPath]: patchedContent },
|
|
1311
|
+
readFile: async () => originalContent,
|
|
1312
|
+
writeFile: writeProjectFile,
|
|
1313
|
+
recordEdit: editHistory.recordEdit,
|
|
1314
|
+
});
|
|
1164
1315
|
|
|
1165
1316
|
setRefreshKey((k) => k + 1);
|
|
1166
1317
|
},
|
|
1167
|
-
[activeCompPath, timelineElements],
|
|
1318
|
+
[activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
|
|
1168
1319
|
);
|
|
1169
1320
|
|
|
1170
1321
|
const handleTimelineElementResize = useCallback(
|
|
@@ -1236,25 +1387,19 @@ export function StudioApp() {
|
|
|
1236
1387
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1237
1388
|
}
|
|
1238
1389
|
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
if (editingPathRef.current === targetPath) {
|
|
1252
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1253
|
-
}
|
|
1390
|
+
await saveProjectFilesWithHistory({
|
|
1391
|
+
projectId: pid,
|
|
1392
|
+
label: "Resize timeline clip",
|
|
1393
|
+
kind: "timeline",
|
|
1394
|
+
files: { [targetPath]: patchedContent },
|
|
1395
|
+
readFile: async () => originalContent,
|
|
1396
|
+
writeFile: writeProjectFile,
|
|
1397
|
+
recordEdit: editHistory.recordEdit,
|
|
1398
|
+
});
|
|
1254
1399
|
|
|
1255
1400
|
setRefreshKey((k) => k + 1);
|
|
1256
1401
|
},
|
|
1257
|
-
[activeCompPath],
|
|
1402
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1258
1403
|
);
|
|
1259
1404
|
|
|
1260
1405
|
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
@@ -1263,6 +1408,42 @@ export function StudioApp() {
|
|
|
1263
1408
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
1264
1409
|
}, []);
|
|
1265
1410
|
|
|
1411
|
+
const handleCaptureFrameClick = useCallback(
|
|
1412
|
+
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
1413
|
+
if (!projectId) return;
|
|
1414
|
+
event.preventDefault();
|
|
1415
|
+
|
|
1416
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
1417
|
+
setCaptureFrameTime(currentTime);
|
|
1418
|
+
const href = buildFrameCaptureUrl({
|
|
1419
|
+
projectId,
|
|
1420
|
+
compositionPath: activeCompPath,
|
|
1421
|
+
currentTime,
|
|
1422
|
+
});
|
|
1423
|
+
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
|
|
1424
|
+
|
|
1425
|
+
try {
|
|
1426
|
+
const response = await fetch(href, { cache: "no-store" });
|
|
1427
|
+
if (!response.ok) {
|
|
1428
|
+
throw new Error(`Capture failed (${response.status})`);
|
|
1429
|
+
}
|
|
1430
|
+
const blob = await response.blob();
|
|
1431
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1432
|
+
const link = document.createElement("a");
|
|
1433
|
+
link.href = blobUrl;
|
|
1434
|
+
link.download = filename;
|
|
1435
|
+
document.body.appendChild(link);
|
|
1436
|
+
link.click();
|
|
1437
|
+
link.remove();
|
|
1438
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
const message = err instanceof Error ? err.message : "Capture failed";
|
|
1441
|
+
showToast(message);
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
[activeCompPath, projectId, showToast],
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1266
1447
|
const handleTimelineElementDelete = useCallback(
|
|
1267
1448
|
async (element: TimelineElement) => {
|
|
1268
1449
|
const pid = projectIdRef.current;
|
|
@@ -1343,21 +1524,15 @@ export function StudioApp() {
|
|
|
1343
1524
|
});
|
|
1344
1525
|
}
|
|
1345
1526
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
if (editingPathRef.current === targetPath) {
|
|
1359
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1360
|
-
}
|
|
1527
|
+
await saveProjectFilesWithHistory({
|
|
1528
|
+
projectId: pid,
|
|
1529
|
+
label: "Delete timeline clip",
|
|
1530
|
+
kind: "timeline",
|
|
1531
|
+
files: { [targetPath]: patchedContent },
|
|
1532
|
+
readFile: async () => originalContent,
|
|
1533
|
+
writeFile: writeProjectFile,
|
|
1534
|
+
recordEdit: editHistory.recordEdit,
|
|
1535
|
+
});
|
|
1361
1536
|
|
|
1362
1537
|
usePlayerStore
|
|
1363
1538
|
.getState()
|
|
@@ -1374,7 +1549,7 @@ export function StudioApp() {
|
|
|
1374
1549
|
showToast(message);
|
|
1375
1550
|
}
|
|
1376
1551
|
},
|
|
1377
|
-
[activeCompPath, showToast, timelineElements],
|
|
1552
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1378
1553
|
);
|
|
1379
1554
|
|
|
1380
1555
|
const handleBlockedTimelineEdit = useCallback(
|
|
@@ -1406,6 +1581,7 @@ export function StudioApp() {
|
|
|
1406
1581
|
const applyDomSelection = useCallback(
|
|
1407
1582
|
(selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
|
|
1408
1583
|
setDomEditSelection(selection);
|
|
1584
|
+
setAgentPromptTagSnippet(undefined);
|
|
1409
1585
|
setCopiedAgentPrompt(false);
|
|
1410
1586
|
if (selection) {
|
|
1411
1587
|
if (options?.revealPanel !== false) {
|
|
@@ -1426,6 +1602,63 @@ export function StudioApp() {
|
|
|
1426
1602
|
applyDomSelection(null, { revealPanel: false });
|
|
1427
1603
|
}, [applyDomSelection]);
|
|
1428
1604
|
|
|
1605
|
+
const handleUndo = useCallback(async () => {
|
|
1606
|
+
const result = await editHistory.undo({
|
|
1607
|
+
readFile: readProjectFile,
|
|
1608
|
+
writeFile: writeProjectFile,
|
|
1609
|
+
});
|
|
1610
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
1611
|
+
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (result.ok && result.label) {
|
|
1615
|
+
clearDomSelection();
|
|
1616
|
+
setRefreshKey((key) => key + 1);
|
|
1617
|
+
showToast(`Undid ${result.label}`, "info");
|
|
1618
|
+
}
|
|
1619
|
+
}, [clearDomSelection, editHistory, readProjectFile, showToast, writeProjectFile]);
|
|
1620
|
+
|
|
1621
|
+
const handleRedo = useCallback(async () => {
|
|
1622
|
+
const result = await editHistory.redo({
|
|
1623
|
+
readFile: readProjectFile,
|
|
1624
|
+
writeFile: writeProjectFile,
|
|
1625
|
+
});
|
|
1626
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
1627
|
+
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (result.ok && result.label) {
|
|
1631
|
+
clearDomSelection();
|
|
1632
|
+
setRefreshKey((key) => key + 1);
|
|
1633
|
+
showToast(`Redid ${result.label}`, "info");
|
|
1634
|
+
}
|
|
1635
|
+
}, [clearDomSelection, editHistory, readProjectFile, showToast, writeProjectFile]);
|
|
1636
|
+
|
|
1637
|
+
const handleUndoRef = useRef(handleUndo);
|
|
1638
|
+
const handleRedoRef = useRef(handleRedo);
|
|
1639
|
+
handleUndoRef.current = handleUndo;
|
|
1640
|
+
handleRedoRef.current = handleRedo;
|
|
1641
|
+
|
|
1642
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1643
|
+
useEffect(() => {
|
|
1644
|
+
const handler = (event: KeyboardEvent) => {
|
|
1645
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
1646
|
+
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
|
1647
|
+
const key = event.key.toLowerCase();
|
|
1648
|
+
if (key === "z" && !event.shiftKey) {
|
|
1649
|
+
event.preventDefault();
|
|
1650
|
+
void handleUndoRef.current();
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
1654
|
+
event.preventDefault();
|
|
1655
|
+
void handleRedoRef.current();
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
window.addEventListener("keydown", handler);
|
|
1659
|
+
return () => window.removeEventListener("keydown", handler);
|
|
1660
|
+
}, []);
|
|
1661
|
+
|
|
1429
1662
|
const buildDomSelectionFromTarget = useCallback(
|
|
1430
1663
|
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
1431
1664
|
if (isMasterView) {
|
|
@@ -1468,6 +1701,34 @@ export function StudioApp() {
|
|
|
1468
1701
|
[activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
|
|
1469
1702
|
);
|
|
1470
1703
|
|
|
1704
|
+
const preloadAgentPromptSnippet = useCallback(
|
|
1705
|
+
async (selection: DomEditSelection) => {
|
|
1706
|
+
const pid = projectIdRef.current;
|
|
1707
|
+
if (!pid) return;
|
|
1708
|
+
|
|
1709
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1710
|
+
try {
|
|
1711
|
+
const response = await fetch(
|
|
1712
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1713
|
+
);
|
|
1714
|
+
if (!response.ok) return;
|
|
1715
|
+
|
|
1716
|
+
const data = (await response.json()) as { content?: string };
|
|
1717
|
+
const html = data.content;
|
|
1718
|
+
const tagSnippet =
|
|
1719
|
+
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
1720
|
+
|
|
1721
|
+
setAgentPromptTagSnippet((current) => {
|
|
1722
|
+
if (domEditSelectionRef.current !== selection) return current;
|
|
1723
|
+
return tagSnippet;
|
|
1724
|
+
});
|
|
1725
|
+
} catch {
|
|
1726
|
+
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
[activeCompPath],
|
|
1730
|
+
);
|
|
1731
|
+
|
|
1471
1732
|
const resolveImportedFontAsset = useCallback(
|
|
1472
1733
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
1473
1734
|
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
@@ -1496,6 +1757,8 @@ export function StudioApp() {
|
|
|
1496
1757
|
selection: DomEditSelection,
|
|
1497
1758
|
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
1498
1759
|
options?: {
|
|
1760
|
+
label?: string;
|
|
1761
|
+
coalesceKey?: string;
|
|
1499
1762
|
skipRefresh?: boolean;
|
|
1500
1763
|
prepareContent?: (html: string, sourceFile: string) => string;
|
|
1501
1764
|
shouldSave?: () => boolean;
|
|
@@ -1530,21 +1793,16 @@ export function StudioApp() {
|
|
|
1530
1793
|
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
1531
1794
|
}
|
|
1532
1795
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
if (editingPathRef.current === targetPath) {
|
|
1546
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1547
|
-
}
|
|
1796
|
+
await saveProjectFilesWithHistory({
|
|
1797
|
+
projectId: pid,
|
|
1798
|
+
label: options?.label ?? "Edit layer",
|
|
1799
|
+
kind: "manual",
|
|
1800
|
+
coalesceKey: options?.coalesceKey,
|
|
1801
|
+
files: { [targetPath]: patchedContent },
|
|
1802
|
+
readFile: async () => originalContent,
|
|
1803
|
+
writeFile: writeProjectFile,
|
|
1804
|
+
recordEdit: editHistory.recordEdit,
|
|
1805
|
+
});
|
|
1548
1806
|
|
|
1549
1807
|
if (options?.skipRefresh) {
|
|
1550
1808
|
domEditSaveTimestampRef.current = Date.now();
|
|
@@ -1552,7 +1810,7 @@ export function StudioApp() {
|
|
|
1552
1810
|
setRefreshKey((k) => k + 1);
|
|
1553
1811
|
}
|
|
1554
1812
|
},
|
|
1555
|
-
[activeCompPath],
|
|
1813
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1556
1814
|
);
|
|
1557
1815
|
|
|
1558
1816
|
const handleDomMoveCommit = useCallback(
|
|
@@ -1563,7 +1821,11 @@ export function StudioApp() {
|
|
|
1563
1821
|
...buildDomEditMovePatchOperations(next.left, next.top),
|
|
1564
1822
|
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1565
1823
|
],
|
|
1566
|
-
{
|
|
1824
|
+
{
|
|
1825
|
+
skipRefresh: true,
|
|
1826
|
+
label: "Move layer",
|
|
1827
|
+
coalesceKey: getDomEditCoalesceKey(selection, "move"),
|
|
1828
|
+
},
|
|
1567
1829
|
);
|
|
1568
1830
|
},
|
|
1569
1831
|
[persistDomEditOperations],
|
|
@@ -1581,7 +1843,11 @@ export function StudioApp() {
|
|
|
1581
1843
|
...buildDomEditResizePatchOperations(next.width, next.height),
|
|
1582
1844
|
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1583
1845
|
],
|
|
1584
|
-
{
|
|
1846
|
+
{
|
|
1847
|
+
skipRefresh: true,
|
|
1848
|
+
label: "Resize layer",
|
|
1849
|
+
coalesceKey: getDomEditCoalesceKey(selection, "resize"),
|
|
1850
|
+
},
|
|
1585
1851
|
);
|
|
1586
1852
|
},
|
|
1587
1853
|
[persistDomEditOperations],
|
|
@@ -1607,7 +1873,10 @@ export function StudioApp() {
|
|
|
1607
1873
|
element.style.setProperty(operation.property, operation.value);
|
|
1608
1874
|
}
|
|
1609
1875
|
|
|
1610
|
-
await persistDomEditOperations(selection, operations, {
|
|
1876
|
+
await persistDomEditOperations(selection, operations, {
|
|
1877
|
+
skipRefresh: true,
|
|
1878
|
+
label: "Make layer movable",
|
|
1879
|
+
});
|
|
1611
1880
|
|
|
1612
1881
|
const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
|
|
1613
1882
|
if (refreshed) {
|
|
@@ -1670,6 +1939,7 @@ export function StudioApp() {
|
|
|
1670
1939
|
);
|
|
1671
1940
|
}
|
|
1672
1941
|
await persistDomEditOperations(domEditSelection, operations, {
|
|
1942
|
+
label: "Edit layer style",
|
|
1673
1943
|
skipRefresh: true,
|
|
1674
1944
|
prepareContent: importedFont
|
|
1675
1945
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
@@ -1714,6 +1984,7 @@ export function StudioApp() {
|
|
|
1714
1984
|
domEditSelection,
|
|
1715
1985
|
[buildDomEditTextPatchOperation(nextContent)],
|
|
1716
1986
|
{
|
|
1987
|
+
label: "Edit text",
|
|
1717
1988
|
skipRefresh: true,
|
|
1718
1989
|
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
1719
1990
|
},
|
|
@@ -1766,6 +2037,7 @@ export function StudioApp() {
|
|
|
1766
2037
|
|
|
1767
2038
|
const importedFont = options?.importedFont ?? null;
|
|
1768
2039
|
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
2040
|
+
label: "Edit text",
|
|
1769
2041
|
skipRefresh: true,
|
|
1770
2042
|
prepareContent: importedFont
|
|
1771
2043
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
@@ -1870,43 +2142,29 @@ export function StudioApp() {
|
|
|
1870
2142
|
|
|
1871
2143
|
const handleAskAgent = useCallback(() => {
|
|
1872
2144
|
if (!domEditSelection) return;
|
|
2145
|
+
setAgentPromptTagSnippet(undefined);
|
|
2146
|
+
void preloadAgentPromptSnippet(domEditSelection);
|
|
1873
2147
|
setAgentModalOpen(true);
|
|
1874
|
-
}, [domEditSelection]);
|
|
2148
|
+
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
1875
2149
|
|
|
1876
2150
|
const handleAgentModalSubmit = useCallback(
|
|
1877
2151
|
async (userInstruction: string) => {
|
|
1878
2152
|
if (!domEditSelection) return;
|
|
1879
2153
|
|
|
1880
|
-
const pid = projectIdRef.current;
|
|
1881
|
-
if (!pid) return;
|
|
1882
|
-
|
|
1883
2154
|
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
1884
|
-
const
|
|
1885
|
-
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
1886
|
-
|
|
1887
|
-
const data = (await response.json()) as { content?: string };
|
|
1888
|
-
const html = data.content;
|
|
1889
|
-
const tagSnippet =
|
|
1890
|
-
typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
|
|
2155
|
+
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
1891
2156
|
const prompt = buildElementAgentPrompt({
|
|
1892
2157
|
selection: domEditSelection,
|
|
1893
2158
|
currentTime,
|
|
1894
2159
|
tagSnippet,
|
|
1895
2160
|
userInstruction,
|
|
2161
|
+
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
1896
2162
|
});
|
|
1897
2163
|
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
textarea.value = prompt;
|
|
1903
|
-
textarea.setAttribute("readonly", "true");
|
|
1904
|
-
textarea.style.position = "fixed";
|
|
1905
|
-
textarea.style.opacity = "0";
|
|
1906
|
-
document.body.appendChild(textarea);
|
|
1907
|
-
textarea.select();
|
|
1908
|
-
document.execCommand("copy");
|
|
1909
|
-
document.body.removeChild(textarea);
|
|
2164
|
+
const copied = await copyTextToClipboard(prompt);
|
|
2165
|
+
if (!copied) {
|
|
2166
|
+
showToast("Could not copy prompt to clipboard.", "error");
|
|
2167
|
+
return;
|
|
1910
2168
|
}
|
|
1911
2169
|
|
|
1912
2170
|
setAgentModalOpen(false);
|
|
@@ -1914,7 +2172,7 @@ export function StudioApp() {
|
|
|
1914
2172
|
setCopiedAgentPrompt(true);
|
|
1915
2173
|
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
1916
2174
|
},
|
|
1917
|
-
[activeCompPath, currentTime, domEditSelection],
|
|
2175
|
+
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
|
1918
2176
|
);
|
|
1919
2177
|
|
|
1920
2178
|
const handlePreviewIframeRef = useCallback(
|
|
@@ -1929,7 +2187,7 @@ export function StudioApp() {
|
|
|
1929
2187
|
);
|
|
1930
2188
|
|
|
1931
2189
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
1932
|
-
(e: React.MouseEvent<HTMLDivElement
|
|
2190
|
+
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
1933
2191
|
const iframe = previewIframeRef.current;
|
|
1934
2192
|
if (!iframe || captionEditMode) return;
|
|
1935
2193
|
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
@@ -1941,7 +2199,7 @@ export function StudioApp() {
|
|
|
1941
2199
|
e.preventDefault();
|
|
1942
2200
|
e.stopPropagation();
|
|
1943
2201
|
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
1944
|
-
preferClipAncestor: true,
|
|
2202
|
+
preferClipAncestor: options?.preferClipAncestor ?? true,
|
|
1945
2203
|
});
|
|
1946
2204
|
if (!nextSelection) {
|
|
1947
2205
|
lastPreviewClickRef.current = null;
|
|
@@ -2139,7 +2397,11 @@ export function StudioApp() {
|
|
|
2139
2397
|
);
|
|
2140
2398
|
|
|
2141
2399
|
const handleTimelineAssetDrop = useCallback(
|
|
2142
|
-
async (
|
|
2400
|
+
async (
|
|
2401
|
+
assetPath: string,
|
|
2402
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
2403
|
+
durationOverride?: number,
|
|
2404
|
+
) => {
|
|
2143
2405
|
const pid = projectIdRef.current;
|
|
2144
2406
|
if (!pid) throw new Error("No active project");
|
|
2145
2407
|
|
|
@@ -2165,9 +2427,11 @@ export function StudioApp() {
|
|
|
2165
2427
|
}
|
|
2166
2428
|
|
|
2167
2429
|
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
2168
|
-
const
|
|
2169
|
-
|
|
2170
|
-
|
|
2430
|
+
const duration =
|
|
2431
|
+
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
2432
|
+
? durationOverride
|
|
2433
|
+
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
2434
|
+
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
2171
2435
|
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
2172
2436
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
2173
2437
|
|
|
@@ -2219,21 +2483,15 @@ export function StudioApp() {
|
|
|
2219
2483
|
}),
|
|
2220
2484
|
);
|
|
2221
2485
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
if (editingPathRef.current === targetPath) {
|
|
2235
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
2236
|
-
}
|
|
2486
|
+
await saveProjectFilesWithHistory({
|
|
2487
|
+
projectId: pid,
|
|
2488
|
+
label: "Add timeline asset",
|
|
2489
|
+
kind: "timeline",
|
|
2490
|
+
files: { [targetPath]: patchedContent },
|
|
2491
|
+
readFile: async () => originalContent,
|
|
2492
|
+
writeFile: writeProjectFile,
|
|
2493
|
+
recordEdit: editHistory.recordEdit,
|
|
2494
|
+
});
|
|
2237
2495
|
|
|
2238
2496
|
setRefreshKey((k) => k + 1);
|
|
2239
2497
|
} catch (error) {
|
|
@@ -2242,22 +2500,45 @@ export function StudioApp() {
|
|
|
2242
2500
|
showToast(message);
|
|
2243
2501
|
}
|
|
2244
2502
|
},
|
|
2245
|
-
[activeCompPath, showToast, timelineElements],
|
|
2503
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
2246
2504
|
);
|
|
2247
2505
|
|
|
2248
2506
|
const handleTimelineFileDrop = useCallback(
|
|
2249
2507
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
2508
|
+
const pid = projectIdRef.current;
|
|
2509
|
+
if (!pid) return;
|
|
2250
2510
|
const uploaded = await uploadProjectFiles(files);
|
|
2251
2511
|
if (uploaded.length === 0) return;
|
|
2512
|
+
const durations: number[] = [];
|
|
2513
|
+
for (const assetPath of uploaded) {
|
|
2514
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
2515
|
+
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
|
2516
|
+
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
|
2517
|
+
}
|
|
2252
2518
|
const placements = buildTimelineFileDropPlacements(
|
|
2253
2519
|
placement ?? { start: 0, track: 0 },
|
|
2254
|
-
|
|
2520
|
+
durations,
|
|
2521
|
+
timelineElements
|
|
2522
|
+
.filter(
|
|
2523
|
+
(timelineElement) =>
|
|
2524
|
+
(timelineElement.sourceFile || activeCompPath || "index.html") ===
|
|
2525
|
+
(activeCompPath || "index.html"),
|
|
2526
|
+
)
|
|
2527
|
+
.map((timelineElement) => ({
|
|
2528
|
+
start: timelineElement.start,
|
|
2529
|
+
duration: timelineElement.duration,
|
|
2530
|
+
track: timelineElement.track,
|
|
2531
|
+
})),
|
|
2255
2532
|
);
|
|
2256
2533
|
for (const [index, assetPath] of uploaded.entries()) {
|
|
2257
|
-
await handleTimelineAssetDrop(
|
|
2534
|
+
await handleTimelineAssetDrop(
|
|
2535
|
+
assetPath,
|
|
2536
|
+
placements[index] ?? placements[0],
|
|
2537
|
+
durations[index],
|
|
2538
|
+
);
|
|
2258
2539
|
}
|
|
2259
2540
|
},
|
|
2260
|
-
[handleTimelineAssetDrop, uploadProjectFiles],
|
|
2541
|
+
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
|
2261
2542
|
);
|
|
2262
2543
|
|
|
2263
2544
|
// ── File Management Handlers ──
|
|
@@ -2532,54 +2813,54 @@ export function StudioApp() {
|
|
|
2532
2813
|
{/* Right: toolbar buttons */}
|
|
2533
2814
|
<div className="flex items-center gap-1.5">
|
|
2534
2815
|
<button
|
|
2535
|
-
|
|
2816
|
+
type="button"
|
|
2817
|
+
onClick={() => void handleUndo()}
|
|
2818
|
+
disabled={!editHistory.canUndo}
|
|
2536
2819
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
2537
|
-
|
|
2538
|
-
? "
|
|
2539
|
-
: "
|
|
2820
|
+
editHistory.canUndo
|
|
2821
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
2822
|
+
: "border-neutral-900 text-neutral-700"
|
|
2540
2823
|
}`}
|
|
2541
|
-
title={
|
|
2824
|
+
title={
|
|
2825
|
+
editHistory.undoLabel
|
|
2826
|
+
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
|
2827
|
+
: `Undo (${getHistoryShortcutLabel("undo")})`
|
|
2828
|
+
}
|
|
2829
|
+
aria-label="Undo"
|
|
2542
2830
|
>
|
|
2543
|
-
<
|
|
2544
|
-
width="14"
|
|
2545
|
-
height="14"
|
|
2546
|
-
viewBox="0 0 24 24"
|
|
2547
|
-
fill="none"
|
|
2548
|
-
stroke="currentColor"
|
|
2549
|
-
strokeWidth="1.5"
|
|
2550
|
-
strokeLinecap="round"
|
|
2551
|
-
strokeLinejoin="round"
|
|
2552
|
-
>
|
|
2553
|
-
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
2554
|
-
<path d="M9 3v18" />
|
|
2555
|
-
</svg>
|
|
2831
|
+
<RotateCcw size={14} />
|
|
2556
2832
|
</button>
|
|
2557
2833
|
<button
|
|
2558
2834
|
type="button"
|
|
2559
|
-
onClick={
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2835
|
+
onClick={() => void handleRedo()}
|
|
2836
|
+
disabled={!editHistory.canRedo}
|
|
2837
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
2838
|
+
editHistory.canRedo
|
|
2839
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
2840
|
+
: "border-neutral-900 text-neutral-700"
|
|
2564
2841
|
}`}
|
|
2565
|
-
title={
|
|
2566
|
-
|
|
2842
|
+
title={
|
|
2843
|
+
editHistory.redoLabel
|
|
2844
|
+
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
|
2845
|
+
: `Redo (${getHistoryShortcutLabel("redo")})`
|
|
2846
|
+
}
|
|
2847
|
+
aria-label="Redo"
|
|
2567
2848
|
>
|
|
2568
|
-
<
|
|
2569
|
-
width="14"
|
|
2570
|
-
height="14"
|
|
2571
|
-
viewBox="0 0 24 24"
|
|
2572
|
-
fill="none"
|
|
2573
|
-
stroke="currentColor"
|
|
2574
|
-
strokeWidth="1.5"
|
|
2575
|
-
strokeLinecap="round"
|
|
2576
|
-
>
|
|
2577
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
2578
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
2579
|
-
<line x1="3" y1="5" x2="21" y2="5" />
|
|
2580
|
-
</svg>
|
|
2581
|
-
<span>Timeline</span>
|
|
2849
|
+
<RotateCw size={14} />
|
|
2582
2850
|
</button>
|
|
2851
|
+
<a
|
|
2852
|
+
href={captureFrameHref}
|
|
2853
|
+
download={captureFrameFilename}
|
|
2854
|
+
onClick={handleCaptureFrameClick}
|
|
2855
|
+
onFocus={refreshCaptureFrameTime}
|
|
2856
|
+
onPointerDown={refreshCaptureFrameTime}
|
|
2857
|
+
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
|
2858
|
+
title="Capture current frame"
|
|
2859
|
+
aria-label="Capture current frame"
|
|
2860
|
+
>
|
|
2861
|
+
<Camera size={14} />
|
|
2862
|
+
<span>Capture</span>
|
|
2863
|
+
</a>
|
|
2583
2864
|
<button
|
|
2584
2865
|
onClick={() => {
|
|
2585
2866
|
if (rightCollapsed || rightPanelTab !== "design") {
|
|
@@ -2615,7 +2896,32 @@ export function StudioApp() {
|
|
|
2615
2896
|
{/* Main content: sidebar + preview + right panel */}
|
|
2616
2897
|
<div className="flex flex-1 min-h-0">
|
|
2617
2898
|
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
|
2618
|
-
{
|
|
2899
|
+
{leftCollapsed ? (
|
|
2900
|
+
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
|
|
2901
|
+
<button
|
|
2902
|
+
type="button"
|
|
2903
|
+
onClick={toggleLeftSidebar}
|
|
2904
|
+
className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
2905
|
+
title="Show sidebar"
|
|
2906
|
+
aria-label="Show sidebar"
|
|
2907
|
+
>
|
|
2908
|
+
<svg
|
|
2909
|
+
width="14"
|
|
2910
|
+
height="14"
|
|
2911
|
+
viewBox="0 0 24 24"
|
|
2912
|
+
fill="none"
|
|
2913
|
+
stroke="currentColor"
|
|
2914
|
+
strokeWidth="1.5"
|
|
2915
|
+
strokeLinecap="round"
|
|
2916
|
+
strokeLinejoin="round"
|
|
2917
|
+
aria-hidden="true"
|
|
2918
|
+
>
|
|
2919
|
+
<path d="M5 4v16" />
|
|
2920
|
+
<path d="m10 7 5 5-5 5" />
|
|
2921
|
+
</svg>
|
|
2922
|
+
</button>
|
|
2923
|
+
</div>
|
|
2924
|
+
) : (
|
|
2619
2925
|
<LeftSidebar
|
|
2620
2926
|
width={leftWidth}
|
|
2621
2927
|
projectId={projectId}
|
|
@@ -2662,6 +2968,7 @@ export function StudioApp() {
|
|
|
2662
2968
|
}
|
|
2663
2969
|
onLint={handleLint}
|
|
2664
2970
|
linting={linting}
|
|
2971
|
+
onToggleCollapse={toggleLeftSidebar}
|
|
2665
2972
|
/>
|
|
2666
2973
|
)}
|
|
2667
2974
|
|
|
@@ -2708,6 +3015,7 @@ export function StudioApp() {
|
|
|
2708
3015
|
selection={
|
|
2709
3016
|
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
2710
3017
|
}
|
|
3018
|
+
allowCanvasMovement={false}
|
|
2711
3019
|
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
2712
3020
|
onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
|
|
2713
3021
|
onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
|
|
@@ -2802,6 +3110,7 @@ export function StudioApp() {
|
|
|
2802
3110
|
onImportAssets={handleImportFiles}
|
|
2803
3111
|
fontAssets={fontAssets}
|
|
2804
3112
|
onImportFonts={handleImportFonts}
|
|
3113
|
+
allowLayoutDetach={false}
|
|
2805
3114
|
/>
|
|
2806
3115
|
) : (
|
|
2807
3116
|
<RenderQueue
|