@hyperframes/studio 0.5.0-alpha.10 → 0.5.0-alpha.12
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-JhhmFie-.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 +371 -149
- package/src/components/nle/NLELayout.tsx +43 -8
- 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/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineClip.tsx +2 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +139 -0
- package/src/player/hooks/useTimelinePlayer.ts +201 -89
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -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/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
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,
|
|
@@ -29,6 +38,7 @@ import { useCaptionStore } from "./captions/store";
|
|
|
29
38
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
30
39
|
import { parseCaptionComposition } from "./captions/parser";
|
|
31
40
|
import { copyTextToClipboard } from "./utils/clipboard";
|
|
41
|
+
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
32
42
|
import {
|
|
33
43
|
applyPatchByTarget,
|
|
34
44
|
readAttributeByTarget,
|
|
@@ -50,6 +60,8 @@ import {
|
|
|
50
60
|
setTimelineEditorHintDismissed,
|
|
51
61
|
shouldHandleTimelineToggleHotkey,
|
|
52
62
|
} from "./utils/timelineDiscovery";
|
|
63
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
64
|
+
import { Camera } from "./icons/SystemIcons";
|
|
53
65
|
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
54
66
|
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
55
67
|
import {
|
|
@@ -74,6 +86,7 @@ import {
|
|
|
74
86
|
type DomEditTextField,
|
|
75
87
|
type DomEditSelection,
|
|
76
88
|
} from "./components/editor/domEditing";
|
|
89
|
+
import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
|
|
77
90
|
|
|
78
91
|
interface EditingFile {
|
|
79
92
|
path: string;
|
|
@@ -85,6 +98,10 @@ interface AppToast {
|
|
|
85
98
|
tone: "error" | "info";
|
|
86
99
|
}
|
|
87
100
|
|
|
101
|
+
function getTimelineElementLabel(element: TimelineElement): string {
|
|
102
|
+
return element.label || element.id || element.tag;
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
type RightPanelTab = "design" | "renders";
|
|
89
106
|
|
|
90
107
|
const GENERIC_FONT_FAMILIES = new Set([
|
|
@@ -257,6 +274,29 @@ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
|
257
274
|
return null;
|
|
258
275
|
}
|
|
259
276
|
|
|
277
|
+
function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
278
|
+
const el = getEventTargetElement(target);
|
|
279
|
+
if (!el) return false;
|
|
280
|
+
return Boolean(
|
|
281
|
+
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
286
|
+
const isMac =
|
|
287
|
+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
288
|
+
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
289
|
+
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getDomEditCoalesceKey(
|
|
293
|
+
selection: Pick<DomEditSelection, "id" | "selector" | "sourceFile">,
|
|
294
|
+
action: "move" | "resize",
|
|
295
|
+
): string {
|
|
296
|
+
const target = selection.id || selection.selector || "selection";
|
|
297
|
+
return `${action}:${selection.sourceFile || "index.html"}:${target}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
260
300
|
function findMatchingTimelineElementId(
|
|
261
301
|
selection: Pick<
|
|
262
302
|
DomEditSelection,
|
|
@@ -734,6 +774,7 @@ export function StudioApp() {
|
|
|
734
774
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
735
775
|
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
736
776
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
777
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
737
778
|
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
|
738
779
|
getTimelineEditorHintDismissed,
|
|
739
780
|
);
|
|
@@ -776,6 +817,26 @@ export function StudioApp() {
|
|
|
776
817
|
const toggleTimelineVisibility = useCallback(() => {
|
|
777
818
|
setTimelineVisible((visible) => !visible);
|
|
778
819
|
}, []);
|
|
820
|
+
const toggleLeftSidebar = useCallback(() => {
|
|
821
|
+
setLeftCollapsed((collapsed) => !collapsed);
|
|
822
|
+
}, []);
|
|
823
|
+
const refreshCaptureFrameTime = useCallback(() => {
|
|
824
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
825
|
+
}, []);
|
|
826
|
+
|
|
827
|
+
useMountEffect(() => {
|
|
828
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
829
|
+
return liveTime.subscribe(setCaptureFrameTime);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const captureFrameHref = projectId
|
|
833
|
+
? buildFrameCaptureUrl({
|
|
834
|
+
projectId,
|
|
835
|
+
compositionPath: activeCompPath,
|
|
836
|
+
currentTime: captureFrameTime,
|
|
837
|
+
})
|
|
838
|
+
: "#";
|
|
839
|
+
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
779
840
|
useMountEffect(() => () => {
|
|
780
841
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
781
842
|
});
|
|
@@ -843,7 +904,7 @@ export function StudioApp() {
|
|
|
843
904
|
return (
|
|
844
905
|
<CompositionThumbnail
|
|
845
906
|
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
|
|
846
|
-
label={el
|
|
907
|
+
label={getTimelineElementLabel(el)}
|
|
847
908
|
labelColor={style.label}
|
|
848
909
|
accentColor={style.clip}
|
|
849
910
|
seekTime={0}
|
|
@@ -858,7 +919,7 @@ export function StudioApp() {
|
|
|
858
919
|
return (
|
|
859
920
|
<CompositionThumbnail
|
|
860
921
|
previewUrl={activePreviewUrl}
|
|
861
|
-
label={el
|
|
922
|
+
label={getTimelineElementLabel(el)}
|
|
862
923
|
labelColor={style.label}
|
|
863
924
|
accentColor={style.clip}
|
|
864
925
|
selector={el.selector}
|
|
@@ -896,7 +957,7 @@ export function StudioApp() {
|
|
|
896
957
|
<AudioWaveform
|
|
897
958
|
audioUrl={audioUrl}
|
|
898
959
|
waveformUrl={waveformUrl}
|
|
899
|
-
label={el
|
|
960
|
+
label={getTimelineElementLabel(el)}
|
|
900
961
|
labelColor={style.label}
|
|
901
962
|
/>
|
|
902
963
|
);
|
|
@@ -909,7 +970,7 @@ export function StudioApp() {
|
|
|
909
970
|
return (
|
|
910
971
|
<VideoThumbnail
|
|
911
972
|
videoSrc={mediaSrc}
|
|
912
|
-
label={el
|
|
973
|
+
label={getTimelineElementLabel(el)}
|
|
913
974
|
labelColor={style.label}
|
|
914
975
|
duration={el.duration}
|
|
915
976
|
/>
|
|
@@ -920,7 +981,7 @@ export function StudioApp() {
|
|
|
920
981
|
return (
|
|
921
982
|
<CompositionThumbnail
|
|
922
983
|
previewUrl={`/api/projects/${pid}/preview`}
|
|
923
|
-
label={el
|
|
984
|
+
label={getTimelineElementLabel(el)}
|
|
924
985
|
labelColor={style.label}
|
|
925
986
|
accentColor={style.clip}
|
|
926
987
|
selector={el.selector}
|
|
@@ -1004,6 +1065,28 @@ export function StudioApp() {
|
|
|
1004
1065
|
>
|
|
1005
1066
|
+
|
|
1006
1067
|
</button>
|
|
1068
|
+
<button
|
|
1069
|
+
type="button"
|
|
1070
|
+
onClick={toggleTimelineVisibility}
|
|
1071
|
+
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"
|
|
1072
|
+
title={getTimelineToggleTitle(true)}
|
|
1073
|
+
aria-label="Hide timeline editor"
|
|
1074
|
+
>
|
|
1075
|
+
<svg
|
|
1076
|
+
width="14"
|
|
1077
|
+
height="14"
|
|
1078
|
+
viewBox="0 0 24 24"
|
|
1079
|
+
fill="none"
|
|
1080
|
+
stroke="currentColor"
|
|
1081
|
+
strokeWidth="1.8"
|
|
1082
|
+
strokeLinecap="round"
|
|
1083
|
+
strokeLinejoin="round"
|
|
1084
|
+
aria-hidden="true"
|
|
1085
|
+
>
|
|
1086
|
+
<path d="M5 7h14" />
|
|
1087
|
+
<path d="m8 11 4 4 4-4" />
|
|
1088
|
+
</svg>
|
|
1089
|
+
</button>
|
|
1007
1090
|
</div>
|
|
1008
1091
|
</div>
|
|
1009
1092
|
</div>
|
|
@@ -1090,29 +1173,62 @@ export function StudioApp() {
|
|
|
1090
1173
|
|
|
1091
1174
|
const editingPathRef = useRef(editingFile?.path);
|
|
1092
1175
|
editingPathRef.current = editingFile?.path;
|
|
1176
|
+
const editHistory = usePersistentEditHistory({ projectId });
|
|
1093
1177
|
|
|
1094
|
-
const
|
|
1178
|
+
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1095
1179
|
const pid = projectIdRef.current;
|
|
1096
|
-
if (!pid)
|
|
1097
|
-
const
|
|
1098
|
-
if (!path)
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1180
|
+
if (!pid) throw new Error("No active project");
|
|
1181
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1182
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1183
|
+
const data = (await response.json()) as { content?: string };
|
|
1184
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
1185
|
+
return data.content;
|
|
1186
|
+
}, []);
|
|
1187
|
+
|
|
1188
|
+
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
|
1189
|
+
const pid = projectIdRef.current;
|
|
1190
|
+
if (!pid) throw new Error("No active project");
|
|
1191
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
1192
|
+
method: "PUT",
|
|
1193
|
+
headers: { "Content-Type": "text/plain" },
|
|
1194
|
+
body: content,
|
|
1195
|
+
});
|
|
1196
|
+
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
|
1197
|
+
if (editingPathRef.current === path) {
|
|
1198
|
+
setEditingFile({ path, content });
|
|
1199
|
+
}
|
|
1114
1200
|
}, []);
|
|
1115
1201
|
|
|
1202
|
+
const handleContentChange = useCallback(
|
|
1203
|
+
(content: string) => {
|
|
1204
|
+
const pid = projectIdRef.current;
|
|
1205
|
+
if (!pid) return;
|
|
1206
|
+
const path = editingPathRef.current;
|
|
1207
|
+
if (!path) return;
|
|
1208
|
+
|
|
1209
|
+
// Debounce the server write (600ms)
|
|
1210
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
1211
|
+
saveTimerRef.current = setTimeout(() => {
|
|
1212
|
+
saveProjectFilesWithHistory({
|
|
1213
|
+
projectId: pid,
|
|
1214
|
+
label: "Edit source",
|
|
1215
|
+
kind: "source",
|
|
1216
|
+
coalesceKey: `source:${path}`,
|
|
1217
|
+
files: { [path]: content },
|
|
1218
|
+
readFile: readProjectFile,
|
|
1219
|
+
writeFile: writeProjectFile,
|
|
1220
|
+
recordEdit: editHistory.recordEdit,
|
|
1221
|
+
})
|
|
1222
|
+
.then(() => {
|
|
1223
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1224
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
1225
|
+
})
|
|
1226
|
+
.catch(() => {});
|
|
1227
|
+
}, 600);
|
|
1228
|
+
},
|
|
1229
|
+
[editHistory.recordEdit, readProjectFile, writeProjectFile],
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1116
1232
|
const handleTimelineElementMove = useCallback(
|
|
1117
1233
|
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
1118
1234
|
const pid = projectIdRef.current;
|
|
@@ -1191,25 +1307,19 @@ export function StudioApp() {
|
|
|
1191
1307
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1192
1308
|
}
|
|
1193
1309
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
if (editingPathRef.current === targetPath) {
|
|
1207
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1208
|
-
}
|
|
1310
|
+
await saveProjectFilesWithHistory({
|
|
1311
|
+
projectId: pid,
|
|
1312
|
+
label: "Move timeline clip",
|
|
1313
|
+
kind: "timeline",
|
|
1314
|
+
files: { [targetPath]: patchedContent },
|
|
1315
|
+
readFile: async () => originalContent,
|
|
1316
|
+
writeFile: writeProjectFile,
|
|
1317
|
+
recordEdit: editHistory.recordEdit,
|
|
1318
|
+
});
|
|
1209
1319
|
|
|
1210
1320
|
setRefreshKey((k) => k + 1);
|
|
1211
1321
|
},
|
|
1212
|
-
[activeCompPath, timelineElements],
|
|
1322
|
+
[activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
|
|
1213
1323
|
);
|
|
1214
1324
|
|
|
1215
1325
|
const handleTimelineElementResize = useCallback(
|
|
@@ -1281,25 +1391,19 @@ export function StudioApp() {
|
|
|
1281
1391
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1282
1392
|
}
|
|
1283
1393
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
if (editingPathRef.current === targetPath) {
|
|
1297
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1298
|
-
}
|
|
1394
|
+
await saveProjectFilesWithHistory({
|
|
1395
|
+
projectId: pid,
|
|
1396
|
+
label: "Resize timeline clip",
|
|
1397
|
+
kind: "timeline",
|
|
1398
|
+
files: { [targetPath]: patchedContent },
|
|
1399
|
+
readFile: async () => originalContent,
|
|
1400
|
+
writeFile: writeProjectFile,
|
|
1401
|
+
recordEdit: editHistory.recordEdit,
|
|
1402
|
+
});
|
|
1299
1403
|
|
|
1300
1404
|
setRefreshKey((k) => k + 1);
|
|
1301
1405
|
},
|
|
1302
|
-
[activeCompPath],
|
|
1406
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1303
1407
|
);
|
|
1304
1408
|
|
|
1305
1409
|
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
@@ -1308,6 +1412,42 @@ export function StudioApp() {
|
|
|
1308
1412
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
1309
1413
|
}, []);
|
|
1310
1414
|
|
|
1415
|
+
const handleCaptureFrameClick = useCallback(
|
|
1416
|
+
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
1417
|
+
if (!projectId) return;
|
|
1418
|
+
event.preventDefault();
|
|
1419
|
+
|
|
1420
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
1421
|
+
setCaptureFrameTime(currentTime);
|
|
1422
|
+
const href = buildFrameCaptureUrl({
|
|
1423
|
+
projectId,
|
|
1424
|
+
compositionPath: activeCompPath,
|
|
1425
|
+
currentTime,
|
|
1426
|
+
});
|
|
1427
|
+
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
|
|
1428
|
+
|
|
1429
|
+
try {
|
|
1430
|
+
const response = await fetch(href, { cache: "no-store" });
|
|
1431
|
+
if (!response.ok) {
|
|
1432
|
+
throw new Error(`Capture failed (${response.status})`);
|
|
1433
|
+
}
|
|
1434
|
+
const blob = await response.blob();
|
|
1435
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1436
|
+
const link = document.createElement("a");
|
|
1437
|
+
link.href = blobUrl;
|
|
1438
|
+
link.download = filename;
|
|
1439
|
+
document.body.appendChild(link);
|
|
1440
|
+
link.click();
|
|
1441
|
+
link.remove();
|
|
1442
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
const message = err instanceof Error ? err.message : "Capture failed";
|
|
1445
|
+
showToast(message);
|
|
1446
|
+
}
|
|
1447
|
+
},
|
|
1448
|
+
[activeCompPath, projectId, showToast],
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1311
1451
|
const handleTimelineElementDelete = useCallback(
|
|
1312
1452
|
async (element: TimelineElement) => {
|
|
1313
1453
|
const pid = projectIdRef.current;
|
|
@@ -1388,21 +1528,15 @@ export function StudioApp() {
|
|
|
1388
1528
|
});
|
|
1389
1529
|
}
|
|
1390
1530
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
if (editingPathRef.current === targetPath) {
|
|
1404
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1405
|
-
}
|
|
1531
|
+
await saveProjectFilesWithHistory({
|
|
1532
|
+
projectId: pid,
|
|
1533
|
+
label: "Delete timeline clip",
|
|
1534
|
+
kind: "timeline",
|
|
1535
|
+
files: { [targetPath]: patchedContent },
|
|
1536
|
+
readFile: async () => originalContent,
|
|
1537
|
+
writeFile: writeProjectFile,
|
|
1538
|
+
recordEdit: editHistory.recordEdit,
|
|
1539
|
+
});
|
|
1406
1540
|
|
|
1407
1541
|
usePlayerStore
|
|
1408
1542
|
.getState()
|
|
@@ -1419,7 +1553,7 @@ export function StudioApp() {
|
|
|
1419
1553
|
showToast(message);
|
|
1420
1554
|
}
|
|
1421
1555
|
},
|
|
1422
|
-
[activeCompPath, showToast, timelineElements],
|
|
1556
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1423
1557
|
);
|
|
1424
1558
|
|
|
1425
1559
|
const handleBlockedTimelineEdit = useCallback(
|
|
@@ -1472,6 +1606,63 @@ export function StudioApp() {
|
|
|
1472
1606
|
applyDomSelection(null, { revealPanel: false });
|
|
1473
1607
|
}, [applyDomSelection]);
|
|
1474
1608
|
|
|
1609
|
+
const handleUndo = useCallback(async () => {
|
|
1610
|
+
const result = await editHistory.undo({
|
|
1611
|
+
readFile: readProjectFile,
|
|
1612
|
+
writeFile: writeProjectFile,
|
|
1613
|
+
});
|
|
1614
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
1615
|
+
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (result.ok && result.label) {
|
|
1619
|
+
clearDomSelection();
|
|
1620
|
+
setRefreshKey((key) => key + 1);
|
|
1621
|
+
showToast(`Undid ${result.label}`, "info");
|
|
1622
|
+
}
|
|
1623
|
+
}, [clearDomSelection, editHistory, readProjectFile, showToast, writeProjectFile]);
|
|
1624
|
+
|
|
1625
|
+
const handleRedo = useCallback(async () => {
|
|
1626
|
+
const result = await editHistory.redo({
|
|
1627
|
+
readFile: readProjectFile,
|
|
1628
|
+
writeFile: writeProjectFile,
|
|
1629
|
+
});
|
|
1630
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
1631
|
+
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (result.ok && result.label) {
|
|
1635
|
+
clearDomSelection();
|
|
1636
|
+
setRefreshKey((key) => key + 1);
|
|
1637
|
+
showToast(`Redid ${result.label}`, "info");
|
|
1638
|
+
}
|
|
1639
|
+
}, [clearDomSelection, editHistory, readProjectFile, showToast, writeProjectFile]);
|
|
1640
|
+
|
|
1641
|
+
const handleUndoRef = useRef(handleUndo);
|
|
1642
|
+
const handleRedoRef = useRef(handleRedo);
|
|
1643
|
+
handleUndoRef.current = handleUndo;
|
|
1644
|
+
handleRedoRef.current = handleRedo;
|
|
1645
|
+
|
|
1646
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1647
|
+
useEffect(() => {
|
|
1648
|
+
const handler = (event: KeyboardEvent) => {
|
|
1649
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
1650
|
+
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
|
1651
|
+
const key = event.key.toLowerCase();
|
|
1652
|
+
if (key === "z" && !event.shiftKey) {
|
|
1653
|
+
event.preventDefault();
|
|
1654
|
+
void handleUndoRef.current();
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
1658
|
+
event.preventDefault();
|
|
1659
|
+
void handleRedoRef.current();
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
window.addEventListener("keydown", handler);
|
|
1663
|
+
return () => window.removeEventListener("keydown", handler);
|
|
1664
|
+
}, []);
|
|
1665
|
+
|
|
1475
1666
|
const buildDomSelectionFromTarget = useCallback(
|
|
1476
1667
|
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
1477
1668
|
if (isMasterView) {
|
|
@@ -1570,6 +1761,8 @@ export function StudioApp() {
|
|
|
1570
1761
|
selection: DomEditSelection,
|
|
1571
1762
|
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
1572
1763
|
options?: {
|
|
1764
|
+
label?: string;
|
|
1765
|
+
coalesceKey?: string;
|
|
1573
1766
|
skipRefresh?: boolean;
|
|
1574
1767
|
prepareContent?: (html: string, sourceFile: string) => string;
|
|
1575
1768
|
shouldSave?: () => boolean;
|
|
@@ -1604,21 +1797,16 @@ export function StudioApp() {
|
|
|
1604
1797
|
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
1605
1798
|
}
|
|
1606
1799
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
if (editingPathRef.current === targetPath) {
|
|
1620
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1621
|
-
}
|
|
1800
|
+
await saveProjectFilesWithHistory({
|
|
1801
|
+
projectId: pid,
|
|
1802
|
+
label: options?.label ?? "Edit layer",
|
|
1803
|
+
kind: "manual",
|
|
1804
|
+
coalesceKey: options?.coalesceKey,
|
|
1805
|
+
files: { [targetPath]: patchedContent },
|
|
1806
|
+
readFile: async () => originalContent,
|
|
1807
|
+
writeFile: writeProjectFile,
|
|
1808
|
+
recordEdit: editHistory.recordEdit,
|
|
1809
|
+
});
|
|
1622
1810
|
|
|
1623
1811
|
if (options?.skipRefresh) {
|
|
1624
1812
|
domEditSaveTimestampRef.current = Date.now();
|
|
@@ -1626,7 +1814,7 @@ export function StudioApp() {
|
|
|
1626
1814
|
setRefreshKey((k) => k + 1);
|
|
1627
1815
|
}
|
|
1628
1816
|
},
|
|
1629
|
-
[activeCompPath],
|
|
1817
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1630
1818
|
);
|
|
1631
1819
|
|
|
1632
1820
|
const handleDomMoveCommit = useCallback(
|
|
@@ -1637,7 +1825,11 @@ export function StudioApp() {
|
|
|
1637
1825
|
...buildDomEditMovePatchOperations(next.left, next.top),
|
|
1638
1826
|
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1639
1827
|
],
|
|
1640
|
-
{
|
|
1828
|
+
{
|
|
1829
|
+
skipRefresh: true,
|
|
1830
|
+
label: "Move layer",
|
|
1831
|
+
coalesceKey: getDomEditCoalesceKey(selection, "move"),
|
|
1832
|
+
},
|
|
1641
1833
|
);
|
|
1642
1834
|
},
|
|
1643
1835
|
[persistDomEditOperations],
|
|
@@ -1655,7 +1847,11 @@ export function StudioApp() {
|
|
|
1655
1847
|
...buildDomEditResizePatchOperations(next.width, next.height),
|
|
1656
1848
|
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1657
1849
|
],
|
|
1658
|
-
{
|
|
1850
|
+
{
|
|
1851
|
+
skipRefresh: true,
|
|
1852
|
+
label: "Resize layer",
|
|
1853
|
+
coalesceKey: getDomEditCoalesceKey(selection, "resize"),
|
|
1854
|
+
},
|
|
1659
1855
|
);
|
|
1660
1856
|
},
|
|
1661
1857
|
[persistDomEditOperations],
|
|
@@ -1681,7 +1877,10 @@ export function StudioApp() {
|
|
|
1681
1877
|
element.style.setProperty(operation.property, operation.value);
|
|
1682
1878
|
}
|
|
1683
1879
|
|
|
1684
|
-
await persistDomEditOperations(selection, operations, {
|
|
1880
|
+
await persistDomEditOperations(selection, operations, {
|
|
1881
|
+
skipRefresh: true,
|
|
1882
|
+
label: "Make layer movable",
|
|
1883
|
+
});
|
|
1685
1884
|
|
|
1686
1885
|
const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
|
|
1687
1886
|
if (refreshed) {
|
|
@@ -1744,6 +1943,7 @@ export function StudioApp() {
|
|
|
1744
1943
|
);
|
|
1745
1944
|
}
|
|
1746
1945
|
await persistDomEditOperations(domEditSelection, operations, {
|
|
1946
|
+
label: "Edit layer style",
|
|
1747
1947
|
skipRefresh: true,
|
|
1748
1948
|
prepareContent: importedFont
|
|
1749
1949
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
@@ -1788,6 +1988,7 @@ export function StudioApp() {
|
|
|
1788
1988
|
domEditSelection,
|
|
1789
1989
|
[buildDomEditTextPatchOperation(nextContent)],
|
|
1790
1990
|
{
|
|
1991
|
+
label: "Edit text",
|
|
1791
1992
|
skipRefresh: true,
|
|
1792
1993
|
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
1793
1994
|
},
|
|
@@ -1840,6 +2041,7 @@ export function StudioApp() {
|
|
|
1840
2041
|
|
|
1841
2042
|
const importedFont = options?.importedFont ?? null;
|
|
1842
2043
|
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
2044
|
+
label: "Edit text",
|
|
1843
2045
|
skipRefresh: true,
|
|
1844
2046
|
prepareContent: importedFont
|
|
1845
2047
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
@@ -2285,21 +2487,15 @@ export function StudioApp() {
|
|
|
2285
2487
|
}),
|
|
2286
2488
|
);
|
|
2287
2489
|
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
if (editingPathRef.current === targetPath) {
|
|
2301
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
2302
|
-
}
|
|
2490
|
+
await saveProjectFilesWithHistory({
|
|
2491
|
+
projectId: pid,
|
|
2492
|
+
label: "Add timeline asset",
|
|
2493
|
+
kind: "timeline",
|
|
2494
|
+
files: { [targetPath]: patchedContent },
|
|
2495
|
+
readFile: async () => originalContent,
|
|
2496
|
+
writeFile: writeProjectFile,
|
|
2497
|
+
recordEdit: editHistory.recordEdit,
|
|
2498
|
+
});
|
|
2303
2499
|
|
|
2304
2500
|
setRefreshKey((k) => k + 1);
|
|
2305
2501
|
} catch (error) {
|
|
@@ -2308,7 +2504,7 @@ export function StudioApp() {
|
|
|
2308
2504
|
showToast(message);
|
|
2309
2505
|
}
|
|
2310
2506
|
},
|
|
2311
|
-
[activeCompPath, showToast, timelineElements],
|
|
2507
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
2312
2508
|
);
|
|
2313
2509
|
|
|
2314
2510
|
const handleTimelineFileDrop = useCallback(
|
|
@@ -2621,54 +2817,54 @@ export function StudioApp() {
|
|
|
2621
2817
|
{/* Right: toolbar buttons */}
|
|
2622
2818
|
<div className="flex items-center gap-1.5">
|
|
2623
2819
|
<button
|
|
2624
|
-
|
|
2820
|
+
type="button"
|
|
2821
|
+
onClick={() => void handleUndo()}
|
|
2822
|
+
disabled={!editHistory.canUndo}
|
|
2625
2823
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
2626
|
-
|
|
2627
|
-
? "
|
|
2628
|
-
: "
|
|
2824
|
+
editHistory.canUndo
|
|
2825
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
2826
|
+
: "border-neutral-900 text-neutral-700"
|
|
2629
2827
|
}`}
|
|
2630
|
-
title={
|
|
2828
|
+
title={
|
|
2829
|
+
editHistory.undoLabel
|
|
2830
|
+
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
|
2831
|
+
: `Undo (${getHistoryShortcutLabel("undo")})`
|
|
2832
|
+
}
|
|
2833
|
+
aria-label="Undo"
|
|
2631
2834
|
>
|
|
2632
|
-
<
|
|
2633
|
-
width="14"
|
|
2634
|
-
height="14"
|
|
2635
|
-
viewBox="0 0 24 24"
|
|
2636
|
-
fill="none"
|
|
2637
|
-
stroke="currentColor"
|
|
2638
|
-
strokeWidth="1.5"
|
|
2639
|
-
strokeLinecap="round"
|
|
2640
|
-
strokeLinejoin="round"
|
|
2641
|
-
>
|
|
2642
|
-
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
2643
|
-
<path d="M9 3v18" />
|
|
2644
|
-
</svg>
|
|
2835
|
+
<RotateCcw size={14} />
|
|
2645
2836
|
</button>
|
|
2646
2837
|
<button
|
|
2647
2838
|
type="button"
|
|
2648
|
-
onClick={
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2839
|
+
onClick={() => void handleRedo()}
|
|
2840
|
+
disabled={!editHistory.canRedo}
|
|
2841
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
2842
|
+
editHistory.canRedo
|
|
2843
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
2844
|
+
: "border-neutral-900 text-neutral-700"
|
|
2653
2845
|
}`}
|
|
2654
|
-
title={
|
|
2655
|
-
|
|
2846
|
+
title={
|
|
2847
|
+
editHistory.redoLabel
|
|
2848
|
+
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
|
2849
|
+
: `Redo (${getHistoryShortcutLabel("redo")})`
|
|
2850
|
+
}
|
|
2851
|
+
aria-label="Redo"
|
|
2656
2852
|
>
|
|
2657
|
-
<
|
|
2658
|
-
width="14"
|
|
2659
|
-
height="14"
|
|
2660
|
-
viewBox="0 0 24 24"
|
|
2661
|
-
fill="none"
|
|
2662
|
-
stroke="currentColor"
|
|
2663
|
-
strokeWidth="1.5"
|
|
2664
|
-
strokeLinecap="round"
|
|
2665
|
-
>
|
|
2666
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
2667
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
2668
|
-
<line x1="3" y1="5" x2="21" y2="5" />
|
|
2669
|
-
</svg>
|
|
2670
|
-
<span>Timeline</span>
|
|
2853
|
+
<RotateCw size={14} />
|
|
2671
2854
|
</button>
|
|
2855
|
+
<a
|
|
2856
|
+
href={captureFrameHref}
|
|
2857
|
+
download={captureFrameFilename}
|
|
2858
|
+
onClick={handleCaptureFrameClick}
|
|
2859
|
+
onFocus={refreshCaptureFrameTime}
|
|
2860
|
+
onPointerDown={refreshCaptureFrameTime}
|
|
2861
|
+
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"
|
|
2862
|
+
title="Capture current frame"
|
|
2863
|
+
aria-label="Capture current frame"
|
|
2864
|
+
>
|
|
2865
|
+
<Camera size={14} />
|
|
2866
|
+
<span>Capture</span>
|
|
2867
|
+
</a>
|
|
2672
2868
|
<button
|
|
2673
2869
|
onClick={() => {
|
|
2674
2870
|
if (rightCollapsed || rightPanelTab !== "design") {
|
|
@@ -2704,7 +2900,32 @@ export function StudioApp() {
|
|
|
2704
2900
|
{/* Main content: sidebar + preview + right panel */}
|
|
2705
2901
|
<div className="flex flex-1 min-h-0">
|
|
2706
2902
|
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
|
2707
|
-
{
|
|
2903
|
+
{leftCollapsed ? (
|
|
2904
|
+
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
|
|
2905
|
+
<button
|
|
2906
|
+
type="button"
|
|
2907
|
+
onClick={toggleLeftSidebar}
|
|
2908
|
+
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"
|
|
2909
|
+
title="Show sidebar"
|
|
2910
|
+
aria-label="Show sidebar"
|
|
2911
|
+
>
|
|
2912
|
+
<svg
|
|
2913
|
+
width="14"
|
|
2914
|
+
height="14"
|
|
2915
|
+
viewBox="0 0 24 24"
|
|
2916
|
+
fill="none"
|
|
2917
|
+
stroke="currentColor"
|
|
2918
|
+
strokeWidth="1.5"
|
|
2919
|
+
strokeLinecap="round"
|
|
2920
|
+
strokeLinejoin="round"
|
|
2921
|
+
aria-hidden="true"
|
|
2922
|
+
>
|
|
2923
|
+
<path d="M5 4v16" />
|
|
2924
|
+
<path d="m10 7 5 5-5 5" />
|
|
2925
|
+
</svg>
|
|
2926
|
+
</button>
|
|
2927
|
+
</div>
|
|
2928
|
+
) : (
|
|
2708
2929
|
<LeftSidebar
|
|
2709
2930
|
width={leftWidth}
|
|
2710
2931
|
projectId={projectId}
|
|
@@ -2751,6 +2972,7 @@ export function StudioApp() {
|
|
|
2751
2972
|
}
|
|
2752
2973
|
onLint={handleLint}
|
|
2753
2974
|
linting={linting}
|
|
2975
|
+
onToggleCollapse={toggleLeftSidebar}
|
|
2754
2976
|
/>
|
|
2755
2977
|
)}
|
|
2756
2978
|
|