@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11

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