@hyperframes/studio 0.5.0-alpha.10 → 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 +362 -144
- 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/timelineTheme.ts +3 -3
- package/src/player/hooks/useTimelinePlayer.ts +2 -2
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -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;
|
|
@@ -257,6 +270,29 @@ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
|
257
270
|
return null;
|
|
258
271
|
}
|
|
259
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
|
+
|
|
260
296
|
function findMatchingTimelineElementId(
|
|
261
297
|
selection: Pick<
|
|
262
298
|
DomEditSelection,
|
|
@@ -734,6 +770,7 @@ export function StudioApp() {
|
|
|
734
770
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
735
771
|
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
736
772
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
773
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
737
774
|
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
|
738
775
|
getTimelineEditorHintDismissed,
|
|
739
776
|
);
|
|
@@ -776,6 +813,26 @@ export function StudioApp() {
|
|
|
776
813
|
const toggleTimelineVisibility = useCallback(() => {
|
|
777
814
|
setTimelineVisible((visible) => !visible);
|
|
778
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);
|
|
779
836
|
useMountEffect(() => () => {
|
|
780
837
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
781
838
|
});
|
|
@@ -1004,6 +1061,28 @@ export function StudioApp() {
|
|
|
1004
1061
|
>
|
|
1005
1062
|
+
|
|
1006
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>
|
|
1007
1086
|
</div>
|
|
1008
1087
|
</div>
|
|
1009
1088
|
</div>
|
|
@@ -1090,29 +1169,62 @@ export function StudioApp() {
|
|
|
1090
1169
|
|
|
1091
1170
|
const editingPathRef = useRef(editingFile?.path);
|
|
1092
1171
|
editingPathRef.current = editingFile?.path;
|
|
1172
|
+
const editHistory = usePersistentEditHistory({ projectId });
|
|
1093
1173
|
|
|
1094
|
-
const
|
|
1174
|
+
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1095
1175
|
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
|
-
|
|
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
|
+
}
|
|
1114
1196
|
}, []);
|
|
1115
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
|
+
|
|
1116
1228
|
const handleTimelineElementMove = useCallback(
|
|
1117
1229
|
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
1118
1230
|
const pid = projectIdRef.current;
|
|
@@ -1191,25 +1303,19 @@ export function StudioApp() {
|
|
|
1191
1303
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1192
1304
|
}
|
|
1193
1305
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
1209
1315
|
|
|
1210
1316
|
setRefreshKey((k) => k + 1);
|
|
1211
1317
|
},
|
|
1212
|
-
[activeCompPath, timelineElements],
|
|
1318
|
+
[activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
|
|
1213
1319
|
);
|
|
1214
1320
|
|
|
1215
1321
|
const handleTimelineElementResize = useCallback(
|
|
@@ -1281,25 +1387,19 @@ export function StudioApp() {
|
|
|
1281
1387
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1282
1388
|
}
|
|
1283
1389
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
1299
1399
|
|
|
1300
1400
|
setRefreshKey((k) => k + 1);
|
|
1301
1401
|
},
|
|
1302
|
-
[activeCompPath],
|
|
1402
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1303
1403
|
);
|
|
1304
1404
|
|
|
1305
1405
|
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
@@ -1308,6 +1408,42 @@ export function StudioApp() {
|
|
|
1308
1408
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
1309
1409
|
}, []);
|
|
1310
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
|
+
|
|
1311
1447
|
const handleTimelineElementDelete = useCallback(
|
|
1312
1448
|
async (element: TimelineElement) => {
|
|
1313
1449
|
const pid = projectIdRef.current;
|
|
@@ -1388,21 +1524,15 @@ export function StudioApp() {
|
|
|
1388
1524
|
});
|
|
1389
1525
|
}
|
|
1390
1526
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
1406
1536
|
|
|
1407
1537
|
usePlayerStore
|
|
1408
1538
|
.getState()
|
|
@@ -1419,7 +1549,7 @@ export function StudioApp() {
|
|
|
1419
1549
|
showToast(message);
|
|
1420
1550
|
}
|
|
1421
1551
|
},
|
|
1422
|
-
[activeCompPath, showToast, timelineElements],
|
|
1552
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1423
1553
|
);
|
|
1424
1554
|
|
|
1425
1555
|
const handleBlockedTimelineEdit = useCallback(
|
|
@@ -1472,6 +1602,63 @@ export function StudioApp() {
|
|
|
1472
1602
|
applyDomSelection(null, { revealPanel: false });
|
|
1473
1603
|
}, [applyDomSelection]);
|
|
1474
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
|
+
|
|
1475
1662
|
const buildDomSelectionFromTarget = useCallback(
|
|
1476
1663
|
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
1477
1664
|
if (isMasterView) {
|
|
@@ -1570,6 +1757,8 @@ export function StudioApp() {
|
|
|
1570
1757
|
selection: DomEditSelection,
|
|
1571
1758
|
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
1572
1759
|
options?: {
|
|
1760
|
+
label?: string;
|
|
1761
|
+
coalesceKey?: string;
|
|
1573
1762
|
skipRefresh?: boolean;
|
|
1574
1763
|
prepareContent?: (html: string, sourceFile: string) => string;
|
|
1575
1764
|
shouldSave?: () => boolean;
|
|
@@ -1604,21 +1793,16 @@ export function StudioApp() {
|
|
|
1604
1793
|
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
1605
1794
|
}
|
|
1606
1795
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
if (editingPathRef.current === targetPath) {
|
|
1620
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1621
|
-
}
|
|
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
|
+
});
|
|
1622
1806
|
|
|
1623
1807
|
if (options?.skipRefresh) {
|
|
1624
1808
|
domEditSaveTimestampRef.current = Date.now();
|
|
@@ -1626,7 +1810,7 @@ export function StudioApp() {
|
|
|
1626
1810
|
setRefreshKey((k) => k + 1);
|
|
1627
1811
|
}
|
|
1628
1812
|
},
|
|
1629
|
-
[activeCompPath],
|
|
1813
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1630
1814
|
);
|
|
1631
1815
|
|
|
1632
1816
|
const handleDomMoveCommit = useCallback(
|
|
@@ -1637,7 +1821,11 @@ export function StudioApp() {
|
|
|
1637
1821
|
...buildDomEditMovePatchOperations(next.left, next.top),
|
|
1638
1822
|
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1639
1823
|
],
|
|
1640
|
-
{
|
|
1824
|
+
{
|
|
1825
|
+
skipRefresh: true,
|
|
1826
|
+
label: "Move layer",
|
|
1827
|
+
coalesceKey: getDomEditCoalesceKey(selection, "move"),
|
|
1828
|
+
},
|
|
1641
1829
|
);
|
|
1642
1830
|
},
|
|
1643
1831
|
[persistDomEditOperations],
|
|
@@ -1655,7 +1843,11 @@ export function StudioApp() {
|
|
|
1655
1843
|
...buildDomEditResizePatchOperations(next.width, next.height),
|
|
1656
1844
|
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1657
1845
|
],
|
|
1658
|
-
{
|
|
1846
|
+
{
|
|
1847
|
+
skipRefresh: true,
|
|
1848
|
+
label: "Resize layer",
|
|
1849
|
+
coalesceKey: getDomEditCoalesceKey(selection, "resize"),
|
|
1850
|
+
},
|
|
1659
1851
|
);
|
|
1660
1852
|
},
|
|
1661
1853
|
[persistDomEditOperations],
|
|
@@ -1681,7 +1873,10 @@ export function StudioApp() {
|
|
|
1681
1873
|
element.style.setProperty(operation.property, operation.value);
|
|
1682
1874
|
}
|
|
1683
1875
|
|
|
1684
|
-
await persistDomEditOperations(selection, operations, {
|
|
1876
|
+
await persistDomEditOperations(selection, operations, {
|
|
1877
|
+
skipRefresh: true,
|
|
1878
|
+
label: "Make layer movable",
|
|
1879
|
+
});
|
|
1685
1880
|
|
|
1686
1881
|
const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
|
|
1687
1882
|
if (refreshed) {
|
|
@@ -1744,6 +1939,7 @@ export function StudioApp() {
|
|
|
1744
1939
|
);
|
|
1745
1940
|
}
|
|
1746
1941
|
await persistDomEditOperations(domEditSelection, operations, {
|
|
1942
|
+
label: "Edit layer style",
|
|
1747
1943
|
skipRefresh: true,
|
|
1748
1944
|
prepareContent: importedFont
|
|
1749
1945
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
@@ -1788,6 +1984,7 @@ export function StudioApp() {
|
|
|
1788
1984
|
domEditSelection,
|
|
1789
1985
|
[buildDomEditTextPatchOperation(nextContent)],
|
|
1790
1986
|
{
|
|
1987
|
+
label: "Edit text",
|
|
1791
1988
|
skipRefresh: true,
|
|
1792
1989
|
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
1793
1990
|
},
|
|
@@ -1840,6 +2037,7 @@ export function StudioApp() {
|
|
|
1840
2037
|
|
|
1841
2038
|
const importedFont = options?.importedFont ?? null;
|
|
1842
2039
|
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
2040
|
+
label: "Edit text",
|
|
1843
2041
|
skipRefresh: true,
|
|
1844
2042
|
prepareContent: importedFont
|
|
1845
2043
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
@@ -2285,21 +2483,15 @@ export function StudioApp() {
|
|
|
2285
2483
|
}),
|
|
2286
2484
|
);
|
|
2287
2485
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
2303
2495
|
|
|
2304
2496
|
setRefreshKey((k) => k + 1);
|
|
2305
2497
|
} catch (error) {
|
|
@@ -2308,7 +2500,7 @@ export function StudioApp() {
|
|
|
2308
2500
|
showToast(message);
|
|
2309
2501
|
}
|
|
2310
2502
|
},
|
|
2311
|
-
[activeCompPath, showToast, timelineElements],
|
|
2503
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
2312
2504
|
);
|
|
2313
2505
|
|
|
2314
2506
|
const handleTimelineFileDrop = useCallback(
|
|
@@ -2621,54 +2813,54 @@ export function StudioApp() {
|
|
|
2621
2813
|
{/* Right: toolbar buttons */}
|
|
2622
2814
|
<div className="flex items-center gap-1.5">
|
|
2623
2815
|
<button
|
|
2624
|
-
|
|
2816
|
+
type="button"
|
|
2817
|
+
onClick={() => void handleUndo()}
|
|
2818
|
+
disabled={!editHistory.canUndo}
|
|
2625
2819
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
2626
|
-
|
|
2627
|
-
? "
|
|
2628
|
-
: "
|
|
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"
|
|
2629
2823
|
}`}
|
|
2630
|
-
title={
|
|
2824
|
+
title={
|
|
2825
|
+
editHistory.undoLabel
|
|
2826
|
+
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
|
2827
|
+
: `Undo (${getHistoryShortcutLabel("undo")})`
|
|
2828
|
+
}
|
|
2829
|
+
aria-label="Undo"
|
|
2631
2830
|
>
|
|
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>
|
|
2831
|
+
<RotateCcw size={14} />
|
|
2645
2832
|
</button>
|
|
2646
2833
|
<button
|
|
2647
2834
|
type="button"
|
|
2648
|
-
onClick={
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
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"
|
|
2653
2841
|
}`}
|
|
2654
|
-
title={
|
|
2655
|
-
|
|
2842
|
+
title={
|
|
2843
|
+
editHistory.redoLabel
|
|
2844
|
+
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
|
2845
|
+
: `Redo (${getHistoryShortcutLabel("redo")})`
|
|
2846
|
+
}
|
|
2847
|
+
aria-label="Redo"
|
|
2656
2848
|
>
|
|
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>
|
|
2849
|
+
<RotateCw size={14} />
|
|
2671
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>
|
|
2672
2864
|
<button
|
|
2673
2865
|
onClick={() => {
|
|
2674
2866
|
if (rightCollapsed || rightPanelTab !== "design") {
|
|
@@ -2704,7 +2896,32 @@ export function StudioApp() {
|
|
|
2704
2896
|
{/* Main content: sidebar + preview + right panel */}
|
|
2705
2897
|
<div className="flex flex-1 min-h-0">
|
|
2706
2898
|
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
|
2707
|
-
{
|
|
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
|
+
) : (
|
|
2708
2925
|
<LeftSidebar
|
|
2709
2926
|
width={leftWidth}
|
|
2710
2927
|
projectId={projectId}
|
|
@@ -2751,6 +2968,7 @@ export function StudioApp() {
|
|
|
2751
2968
|
}
|
|
2752
2969
|
onLint={handleLint}
|
|
2753
2970
|
linting={linting}
|
|
2971
|
+
onToggleCollapse={toggleLeftSidebar}
|
|
2754
2972
|
/>
|
|
2755
2973
|
)}
|
|
2756
2974
|
|