@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/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;
@@ -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 handleContentChange = useCallback((content: string) => {
1174
+ const readProjectFile = useCallback(async (path: string): Promise<string> => {
1095
1175
  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);
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- { skipRefresh: true },
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
- { skipRefresh: true },
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, { skipRefresh: true });
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
- 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
- }
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
- onClick={() => setLeftCollapsed((v) => !v)}
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
- !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"
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={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
2824
+ title={
2825
+ editHistory.undoLabel
2826
+ ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
2827
+ : `Undo (${getHistoryShortcutLabel("undo")})`
2828
+ }
2829
+ aria-label="Undo"
2631
2830
  >
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>
2831
+ <RotateCcw size={14} />
2645
2832
  </button>
2646
2833
  <button
2647
2834
  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"
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={getTimelineToggleTitle(timelineVisible)}
2655
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
2842
+ title={
2843
+ editHistory.redoLabel
2844
+ ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
2845
+ : `Redo (${getHistoryShortcutLabel("redo")})`
2846
+ }
2847
+ aria-label="Redo"
2656
2848
  >
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>
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
- {!leftCollapsed && (
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