@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/src/App.tsx CHANGED
@@ -1,16 +1,25 @@
1
- import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
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.id || el.tag}
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.id || el.tag}
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.id || el.tag}
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.id || el.tag}
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.id || el.tag}
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 handleContentChange = useCallback((content: string) => {
1178
+ const readProjectFile = useCallback(async (path: string): Promise<string> => {
1095
1179
  const pid = projectIdRef.current;
1096
- if (!pid) return;
1097
- const path = editingPathRef.current;
1098
- if (!path) return;
1099
-
1100
- // Debounce the server write (600ms)
1101
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1102
- saveTimerRef.current = setTimeout(() => {
1103
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
1104
- method: "PUT",
1105
- headers: { "Content-Type": "text/plain" },
1106
- body: content,
1107
- })
1108
- .then(() => {
1109
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1110
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
1111
- })
1112
- .catch(() => {});
1113
- }, 600);
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
- const saveResponse = await fetch(
1195
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1196
- {
1197
- method: "PUT",
1198
- headers: { "Content-Type": "text/plain" },
1199
- body: patchedContent,
1200
- },
1201
- );
1202
- if (!saveResponse.ok) {
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
- const saveResponse = await fetch(
1285
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1286
- {
1287
- method: "PUT",
1288
- headers: { "Content-Type": "text/plain" },
1289
- body: patchedContent,
1290
- },
1291
- );
1292
- if (!saveResponse.ok) {
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
- const saveResponse = await fetch(
1392
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1393
- {
1394
- method: "PUT",
1395
- headers: { "Content-Type": "text/plain" },
1396
- body: patchedContent,
1397
- },
1398
- );
1399
- if (!saveResponse.ok) {
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
- const saveResponse = await fetch(
1608
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1609
- {
1610
- method: "PUT",
1611
- headers: { "Content-Type": "text/plain" },
1612
- body: patchedContent,
1613
- },
1614
- );
1615
- if (!saveResponse.ok) {
1616
- throw new Error(`Failed to save ${targetPath}`);
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
- { skipRefresh: true },
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
- { skipRefresh: true },
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, { skipRefresh: true });
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
- const saveResponse = await fetch(
2289
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
2290
- {
2291
- method: "PUT",
2292
- headers: { "Content-Type": "text/plain" },
2293
- body: patchedContent,
2294
- },
2295
- );
2296
- if (!saveResponse.ok) {
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
- onClick={() => setLeftCollapsed((v) => !v)}
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
- !leftCollapsed
2627
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
2628
- : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
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={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
2828
+ title={
2829
+ editHistory.undoLabel
2830
+ ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
2831
+ : `Undo (${getHistoryShortcutLabel("undo")})`
2832
+ }
2833
+ aria-label="Undo"
2631
2834
  >
2632
- <svg
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={toggleTimelineVisibility}
2649
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
2650
- timelineVisible
2651
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
2652
- : "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
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={getTimelineToggleTitle(timelineVisible)}
2655
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
2846
+ title={
2847
+ editHistory.redoLabel
2848
+ ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
2849
+ : `Redo (${getHistoryShortcutLabel("redo")})`
2850
+ }
2851
+ aria-label="Redo"
2656
2852
  >
2657
- <svg
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
- {!leftCollapsed && (
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