@hyperframes/studio 0.6.0-alpha.1 → 0.6.0-alpha.10

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
@@ -4,13 +4,14 @@ import {
4
4
  useRef,
5
5
  useEffect,
6
6
  useMemo,
7
+ type CSSProperties,
7
8
  type MouseEvent,
8
9
  type ReactNode,
9
10
  } from "react";
10
11
  import { useMountEffect } from "./hooks/useMountEffect";
11
12
  import { NLELayout } from "./components/nle/NLELayout";
12
13
  import { SourceEditor } from "./components/editor/SourceEditor";
13
- import { LeftSidebar } from "./components/sidebar/LeftSidebar";
14
+ import { LeftSidebar, type LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
14
15
  import { RenderQueue } from "./components/renders/RenderQueue";
15
16
  import { useRenderQueue } from "./components/renders/useRenderQueue";
16
17
  import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
@@ -55,6 +56,7 @@ import {
55
56
  } from "./player/components/timelineZoom";
56
57
  import {
57
58
  getTimelineToggleTitle,
59
+ isEditableTarget,
58
60
  shouldHandleTimelineToggleHotkey,
59
61
  } from "./utils/timelineDiscovery";
60
62
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
@@ -78,10 +80,10 @@ import {
78
80
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
79
81
  STUDIO_MOTION_PANEL_ENABLED,
80
82
  STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
83
+ STUDIO_PREVIEW_SELECTION_ENABLED,
81
84
  STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
82
85
  } from "./components/editor/manualEditingAvailability";
83
86
  import {
84
- buildDefaultDomEditTextField,
85
87
  buildDomEditStylePatchOperation,
86
88
  buildDomEditTextPatchOperation,
87
89
  buildElementAgentPrompt,
@@ -91,12 +93,16 @@ import {
91
93
  findElementForTimelineElement,
92
94
  getDomEditLayerKey,
93
95
  getDomEditTargetKey,
96
+ isLargeRasterDomEditSelection,
94
97
  isTextEditableSelection,
98
+ resolveVisualDomEditSelectionTarget,
95
99
  serializeDomEditTextFields,
96
100
  resolveDomEditSelection,
101
+ type DomEditViewport,
97
102
  type DomEditLayerItem,
98
103
  type DomEditTextField,
99
104
  type DomEditSelection,
105
+ buildDefaultDomEditTextField,
100
106
  } from "./components/editor/domEditing";
101
107
  import {
102
108
  STUDIO_MANUAL_EDITS_PATH,
@@ -358,10 +364,64 @@ function isManualGeometryStyleProperty(property: string): boolean {
358
364
  return property === "left" || property === "top" || property === "width" || property === "height";
359
365
  }
360
366
 
367
+ interface PreviewLocalPointer {
368
+ x: number;
369
+ y: number;
370
+ viewport: DomEditViewport;
371
+ }
372
+
373
+ interface AgentModalAnchorPoint {
374
+ x: number;
375
+ y: number;
376
+ }
377
+
378
+ function resolvePreviewLocalPointer(
379
+ iframe: HTMLIFrameElement,
380
+ doc: Document,
381
+ win: Window,
382
+ clientX: number,
383
+ clientY: number,
384
+ ): PreviewLocalPointer | null {
385
+ const iframeRect = iframe.getBoundingClientRect();
386
+ const root =
387
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
388
+ const rootRect = root?.getBoundingClientRect();
389
+ const rootWidth = rootRect?.width || win.innerWidth;
390
+ const rootHeight = rootRect?.height || win.innerHeight;
391
+ if (!rootWidth || !rootHeight) return null;
392
+
393
+ const scaleX = iframeRect.width / rootWidth;
394
+ const scaleY = iframeRect.height / rootHeight;
395
+ return {
396
+ x: (clientX - iframeRect.left) / scaleX,
397
+ y: (clientY - iframeRect.top) / scaleY,
398
+ viewport: { width: rootWidth, height: rootHeight },
399
+ };
400
+ }
401
+
402
+ function getPreviewLocalPointer(
403
+ iframe: HTMLIFrameElement,
404
+ clientX: number,
405
+ clientY: number,
406
+ ): PreviewLocalPointer | null {
407
+ let doc: Document | null = null;
408
+ let win: Window | null = null;
409
+ try {
410
+ doc = iframe.contentDocument;
411
+ win = iframe.contentWindow;
412
+ } catch {
413
+ return null;
414
+ }
415
+ if (!doc || !win) return null;
416
+
417
+ return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
418
+ }
419
+
361
420
  function getPreviewTargetFromPointer(
362
421
  iframe: HTMLIFrameElement,
363
422
  clientX: number,
364
423
  clientY: number,
424
+ activeCompositionPath: string | null,
365
425
  ): HTMLElement | null {
366
426
  let doc: Document | null = null;
367
427
  let win: Window | null = null;
@@ -373,20 +433,35 @@ function getPreviewTargetFromPointer(
373
433
  }
374
434
  if (!doc || !win) return null;
375
435
 
376
- const iframeRect = iframe.getBoundingClientRect();
377
- const root =
378
- doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
379
- const rootRect = root?.getBoundingClientRect();
380
- const rootWidth = rootRect?.width || win.innerWidth;
381
- const rootHeight = rootRect?.height || win.innerHeight;
382
- if (!rootWidth || !rootHeight) return null;
436
+ const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
437
+ if (!localPointer) return null;
383
438
 
384
- const scaleX = iframeRect.width / rootWidth;
385
- const scaleY = iframeRect.height / rootHeight;
386
- const localX = (clientX - iframeRect.left) / scaleX;
387
- const localY = (clientY - iframeRect.top) / scaleY;
439
+ if (typeof doc.elementsFromPoint === "function") {
440
+ const visualTarget = resolveVisualDomEditSelectionTarget(
441
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
442
+ {
443
+ activeCompositionPath,
444
+ },
445
+ );
446
+ if (visualTarget) return visualTarget;
447
+ }
448
+
449
+ return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
450
+ }
388
451
 
389
- return getEventTargetElement(doc.elementFromPoint(localX, localY));
452
+ function buildRasterClickSelectionContext(
453
+ selection: DomEditSelection,
454
+ localPointer: PreviewLocalPointer,
455
+ ): string {
456
+ return [
457
+ "The user clicked a large raster/background element in the Studio preview.",
458
+ `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
459
+ localPointer.viewport.width,
460
+ )}x${Math.round(localPointer.viewport.height)} composition.`,
461
+ `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
462
+ "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
463
+ "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
464
+ ].join("\n");
390
465
  }
391
466
 
392
467
  function domEditSelectionsTargetSame(
@@ -592,17 +667,47 @@ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number |
592
667
 
593
668
  // ── Ask Agent Modal ──
594
669
 
670
+ function clampNumber(value: number, min: number, max: number): number {
671
+ if (max < min) return min;
672
+ return Math.min(Math.max(value, min), max);
673
+ }
674
+
675
+ function getAgentModalPositionStyle(
676
+ anchorPoint: AgentModalAnchorPoint | null,
677
+ ): CSSProperties | undefined {
678
+ if (!anchorPoint || typeof window === "undefined") return undefined;
679
+
680
+ const modalWidth = 480;
681
+ const estimatedModalHeight = 270;
682
+ const margin = 16;
683
+ const left = clampNumber(
684
+ anchorPoint.x,
685
+ margin + modalWidth / 2,
686
+ window.innerWidth - margin - modalWidth / 2,
687
+ );
688
+ const top = clampNumber(
689
+ anchorPoint.y + 12,
690
+ margin,
691
+ window.innerHeight - margin - estimatedModalHeight,
692
+ );
693
+
694
+ return { left, top, transform: "translateX(-50%)" };
695
+ }
696
+
595
697
  function AskAgentModal({
596
698
  selectionLabel,
699
+ anchorPoint = null,
597
700
  onSubmit,
598
701
  onClose,
599
702
  }: {
600
703
  selectionLabel: string;
704
+ anchorPoint?: AgentModalAnchorPoint | null;
601
705
  onSubmit: (instruction: string) => void;
602
706
  onClose: () => void;
603
707
  }) {
604
708
  const [value, setValue] = useState("");
605
709
  const inputRef = useRef<HTMLTextAreaElement>(null);
710
+ const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
606
711
 
607
712
  useMountEffect(() => {
608
713
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -615,11 +720,18 @@ function AskAgentModal({
615
720
 
616
721
  return (
617
722
  <div
618
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
723
+ className={
724
+ anchorPoint
725
+ ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
726
+ : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
727
+ }
619
728
  onClick={onClose}
620
729
  >
621
730
  <div
622
- className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
731
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
732
+ anchorPoint ? "fixed" : ""
733
+ }`}
734
+ style={modalPositionStyle}
623
735
  onClick={(e) => e.stopPropagation()}
624
736
  >
625
737
  <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
@@ -774,13 +886,17 @@ export function StudioApp() {
774
886
  const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
775
887
  const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
776
888
  const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
889
+ const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
890
+ string | undefined
891
+ >();
892
+ const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
893
+ null,
894
+ );
777
895
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
778
896
  const [agentModalOpen, setAgentModalOpen] = useState(false);
779
897
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
780
898
  const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
781
- const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
782
- ReadonlySet<string>
783
- >(() => new Set());
899
+ const [compositionLoading, setCompositionLoading] = useState(true);
784
900
  const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
785
901
  const refreshPreviewDocumentVersion = useCallback(() => {
786
902
  setPreviewDocumentVersion((version) => version + 1);
@@ -916,6 +1032,8 @@ export function StudioApp() {
916
1032
  const lastBlockedDomMoveToastAtRef = useRef(0);
917
1033
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
918
1034
  const previewHotkeyWindowRef = useRef<Window | null>(null);
1035
+ const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
1036
+ const leftSidebarRef = useRef<LeftSidebarHandle>(null);
919
1037
  const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
920
1038
  const panelDragRef = useRef<{
921
1039
  side: "left" | "right";
@@ -983,34 +1101,31 @@ export function StudioApp() {
983
1101
  [toggleTimelineVisibility],
984
1102
  );
985
1103
 
986
- useMountEffect(() => {
987
- window.addEventListener("keydown", handleTimelineToggleHotkey);
988
- return () => {
989
- window.removeEventListener("keydown", handleTimelineToggleHotkey);
990
- };
991
- });
1104
+ const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
1105
+ handleAppKeyDownRef.current?.(event);
1106
+ }, []);
992
1107
 
993
1108
  const syncPreviewTimelineHotkey = useCallback(
994
1109
  (iframe: HTMLIFrameElement | null) => {
995
1110
  const nextWindow = iframe?.contentWindow ?? null;
996
1111
  if (previewHotkeyWindowRef.current === nextWindow) return;
997
1112
  if (previewHotkeyWindowRef.current) {
998
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1113
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
999
1114
  }
1000
1115
  previewHotkeyWindowRef.current = nextWindow;
1001
- nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey);
1116
+ nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
1002
1117
  },
1003
- [handleTimelineToggleHotkey],
1118
+ [previewAppKeyDownHandler],
1004
1119
  );
1005
1120
 
1006
1121
  useEffect(
1007
1122
  () => () => {
1008
1123
  if (previewHotkeyWindowRef.current) {
1009
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1124
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
1010
1125
  previewHotkeyWindowRef.current = null;
1011
1126
  }
1012
1127
  },
1013
- [handleTimelineToggleHotkey],
1128
+ [previewAppKeyDownHandler],
1014
1129
  );
1015
1130
 
1016
1131
  const renderClipContent = useCallback(
@@ -1393,6 +1508,10 @@ export function StudioApp() {
1393
1508
  // Debounce the server write (600ms)
1394
1509
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1395
1510
  saveTimerRef.current = setTimeout(() => {
1511
+ // Suppress the file-change watcher echo — the save callback triggers
1512
+ // its own refresh, so a second one from the watcher causes a double-reload
1513
+ // race that can leave the player in a non-playable state.
1514
+ domEditSaveTimestampRef.current = Date.now();
1396
1515
  saveProjectFilesWithHistory({
1397
1516
  projectId: pid,
1398
1517
  label: "Edit source",
@@ -1491,6 +1610,7 @@ export function StudioApp() {
1491
1610
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
1492
1611
  }
1493
1612
 
1613
+ domEditSaveTimestampRef.current = Date.now();
1494
1614
  await saveProjectFilesWithHistory({
1495
1615
  projectId: pid,
1496
1616
  label: "Move timeline clip",
@@ -1575,6 +1695,7 @@ export function StudioApp() {
1575
1695
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
1576
1696
  }
1577
1697
 
1698
+ domEditSaveTimestampRef.current = Date.now();
1578
1699
  await saveProjectFilesWithHistory({
1579
1700
  projectId: pid,
1580
1701
  label: "Resize timeline clip",
@@ -1713,6 +1834,7 @@ export function StudioApp() {
1713
1834
  });
1714
1835
  }
1715
1836
 
1837
+ domEditSaveTimestampRef.current = Date.now();
1716
1838
  await saveProjectFilesWithHistory({
1717
1839
  projectId: pid,
1718
1840
  label: "Delete timeline clip",
@@ -1741,6 +1863,155 @@ export function StudioApp() {
1741
1863
  [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
1742
1864
  );
1743
1865
 
1866
+ const handleDomEditElementDelete = useCallback(
1867
+ async (selection: DomEditSelection) => {
1868
+ const pid = projectIdRef.current;
1869
+ if (!pid) return;
1870
+
1871
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
1872
+ try {
1873
+ const response = await fetch(
1874
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1875
+ );
1876
+ if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
1877
+
1878
+ const data = (await response.json()) as { content?: string };
1879
+ const originalContent = data.content;
1880
+ if (typeof originalContent !== "string")
1881
+ throw new Error(`Missing file contents for ${targetPath}`);
1882
+
1883
+ const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
1884
+ ? {
1885
+ id: selection.id,
1886
+ selector: selection.selector,
1887
+ selectorIndex: selection.selectorIndex,
1888
+ }
1889
+ : selection.selector
1890
+ ? { selector: selection.selector, selectorIndex: selection.selectorIndex }
1891
+ : ({} as never);
1892
+ if (!patchTarget.id && !patchTarget.selector) {
1893
+ throw new Error("Selected element has no patchable target");
1894
+ }
1895
+
1896
+ const removeResponse = await fetch(
1897
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
1898
+ {
1899
+ method: "POST",
1900
+ headers: { "Content-Type": "application/json" },
1901
+ body: JSON.stringify({ target: patchTarget }),
1902
+ },
1903
+ );
1904
+ if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
1905
+
1906
+ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
1907
+ const patchedContent =
1908
+ typeof removeData.content === "string" ? removeData.content : originalContent;
1909
+
1910
+ domEditSaveTimestampRef.current = Date.now();
1911
+ await saveProjectFilesWithHistory({
1912
+ projectId: pid,
1913
+ label: "Delete element",
1914
+ kind: "timeline",
1915
+ files: { [targetPath]: patchedContent },
1916
+ readFile: async () => originalContent,
1917
+ writeFile: writeProjectFile,
1918
+ recordEdit: editHistory.recordEdit,
1919
+ });
1920
+
1921
+ domEditSelectionRef.current = null;
1922
+ domEditGroupSelectionsRef.current = [];
1923
+ setDomEditSelection(null);
1924
+ setDomEditGroupSelections([]);
1925
+ usePlayerStore.getState().setSelectedElementId(null);
1926
+ setRefreshKey((k) => k + 1);
1927
+ } catch (error) {
1928
+ const message = error instanceof Error ? error.message : "Failed to delete element";
1929
+ showToast(message);
1930
+ }
1931
+ },
1932
+ [activeCompPath, editHistory.recordEdit, showToast, writeProjectFile],
1933
+ );
1934
+
1935
+ // ── Consolidated keyboard shortcuts ────────────────────────────────
1936
+ // All app-level window keydown handlers live here.
1937
+ // Component-scoped shortcuts (playback J/K/L/Space, caption nudge)
1938
+ // stay in their respective hooks.
1939
+ const handleToggleRef = useRef(handleTimelineToggleHotkey);
1940
+ handleToggleRef.current = handleTimelineToggleHotkey;
1941
+ const handleDeleteRef = useRef(handleTimelineElementDelete);
1942
+ handleDeleteRef.current = handleTimelineElementDelete;
1943
+ const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
1944
+ handleDomEditDeleteRef.current = handleDomEditElementDelete;
1945
+
1946
+ handleAppKeyDownRef.current = (event: KeyboardEvent) => {
1947
+ // Shift+T — toggle timeline
1948
+ handleToggleRef.current(event);
1949
+
1950
+ // Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
1951
+ if (event.metaKey || event.ctrlKey) {
1952
+ if (!shouldIgnoreHistoryShortcut(event.target)) {
1953
+ const key = event.key.toLowerCase();
1954
+ if (key === "z" && !event.shiftKey) {
1955
+ event.preventDefault();
1956
+ void handleUndoRef.current();
1957
+ return;
1958
+ }
1959
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
1960
+ event.preventDefault();
1961
+ void handleRedoRef.current();
1962
+ return;
1963
+ }
1964
+ }
1965
+
1966
+ // Cmd/Ctrl+1 — sidebar: Compositions tab
1967
+ if (event.key === "1") {
1968
+ event.preventDefault();
1969
+ leftSidebarRef.current?.selectTab("compositions");
1970
+ return;
1971
+ }
1972
+
1973
+ // Cmd/Ctrl+2 — sidebar: Assets tab
1974
+ if (event.key === "2") {
1975
+ event.preventDefault();
1976
+ leftSidebarRef.current?.selectTab("assets");
1977
+ return;
1978
+ }
1979
+ }
1980
+
1981
+ // Delete / Backspace — remove selected element (timeline clip or preview selection)
1982
+ if (
1983
+ (event.key === "Delete" || event.key === "Backspace") &&
1984
+ !event.metaKey &&
1985
+ !event.ctrlKey &&
1986
+ !event.altKey &&
1987
+ !isEditableTarget(event.target)
1988
+ ) {
1989
+ const { selectedElementId, elements } = usePlayerStore.getState();
1990
+ if (selectedElementId) {
1991
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
1992
+ if (element) {
1993
+ event.preventDefault();
1994
+ void handleDeleteRef.current(element);
1995
+ return;
1996
+ }
1997
+ }
1998
+ const domSelection = domEditSelectionRef.current;
1999
+ if (domSelection) {
2000
+ event.preventDefault();
2001
+ void handleDomEditDeleteRef.current(domSelection);
2002
+ }
2003
+ }
2004
+ };
2005
+
2006
+ // eslint-disable-next-line no-restricted-syntax
2007
+ useEffect(() => {
2008
+ function handleAppKeyDown(event: KeyboardEvent) {
2009
+ handleAppKeyDownRef.current?.(event);
2010
+ }
2011
+ window.addEventListener("keydown", handleAppKeyDown, true);
2012
+ return () => window.removeEventListener("keydown", handleAppKeyDown, true);
2013
+ }, []);
2014
+
1744
2015
  const handleBlockedTimelineEdit = useCallback(
1745
2016
  (_element: TimelineElement) => {
1746
2017
  const now = Date.now();
@@ -1771,6 +2042,8 @@ export function StudioApp() {
1771
2042
  options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1772
2043
  ) => {
1773
2044
  setAgentPromptTagSnippet(undefined);
2045
+ setAgentPromptSelectionContext(undefined);
2046
+ setAgentModalAnchorPoint(null);
1774
2047
  setCopiedAgentPrompt(false);
1775
2048
  if (!selection) {
1776
2049
  domEditSelectionRef.current = null;
@@ -2228,6 +2501,8 @@ export function StudioApp() {
2228
2501
  handleUndoRef.current = handleUndo;
2229
2502
  handleRedoRef.current = handleRedo;
2230
2503
 
2504
+ // History hotkey — no longer has its own window listener (consolidated
2505
+ // handler covers it), but kept as a named callback for iframe forwarding.
2231
2506
  const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
2232
2507
  if (!(event.metaKey || event.ctrlKey)) return;
2233
2508
  if (shouldIgnoreHistoryShortcut(event.target)) return;
@@ -2243,12 +2518,6 @@ export function StudioApp() {
2243
2518
  }
2244
2519
  }, []);
2245
2520
 
2246
- // eslint-disable-next-line no-restricted-syntax
2247
- useEffect(() => {
2248
- window.addEventListener("keydown", handleHistoryHotkey, true);
2249
- return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
2250
- }, [handleHistoryHotkey]);
2251
-
2252
2521
  const syncPreviewHistoryHotkey = useCallback(
2253
2522
  (iframe: HTMLIFrameElement | null) => {
2254
2523
  previewHistoryHotkeyCleanupRef.current?.();
@@ -2296,13 +2565,13 @@ export function StudioApp() {
2296
2565
  (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2297
2566
  const iframe = previewIframeRef.current;
2298
2567
  if (!iframe || captionEditMode) return null;
2299
- const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
2568
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
2300
2569
  if (!target) return null;
2301
2570
  return buildDomSelectionFromTarget(target, {
2302
2571
  preferClipAncestor: options?.preferClipAncestor,
2303
2572
  });
2304
2573
  },
2305
- [buildDomSelectionFromTarget, captionEditMode],
2574
+ [activeCompPath, buildDomSelectionFromTarget, captionEditMode],
2306
2575
  );
2307
2576
 
2308
2577
  const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
@@ -2398,8 +2667,21 @@ export function StudioApp() {
2398
2667
 
2399
2668
  const selection = buildDomSelectionForTimelineElement(element);
2400
2669
  if (selection) applyDomSelection(selection);
2670
+
2671
+ const key = getTimelineElementKey(element);
2672
+ if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
2673
+ setInspectedTimelineElementId(key);
2674
+ setLeftCollapsed(false);
2675
+
2676
+ const iframe = previewIframeRef.current;
2677
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2678
+ seekStudioPreview(iframe, element.start);
2679
+ }
2680
+ } else {
2681
+ setInspectedTimelineElementId(null);
2682
+ }
2401
2683
  },
2402
- [applyDomSelection, buildDomSelectionForTimelineElement],
2684
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
2403
2685
  );
2404
2686
 
2405
2687
  const handleTimelineElementInspect = useCallback(
@@ -2426,17 +2708,6 @@ export function StudioApp() {
2426
2708
  [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2427
2709
  );
2428
2710
 
2429
- const handleToggleTimelineElementThumbnail = useCallback((element: TimelineElement) => {
2430
- const key = getTimelineElementKey(element);
2431
- if (!key) return;
2432
- setThumbnailedTimelineElementIds((current) => {
2433
- const next = new Set(current);
2434
- if (next.has(key)) next.delete(key);
2435
- else next.add(key);
2436
- return next;
2437
- });
2438
- }, []);
2439
-
2440
2711
  const handleTimelineLayerSelect = useCallback(
2441
2712
  (layer: DomEditLayerItem) => {
2442
2713
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
@@ -2816,15 +3087,26 @@ export function StudioApp() {
2816
3087
  buildDomEditStylePatchOperation("background-size", "contain"),
2817
3088
  );
2818
3089
  }
2819
- await persistDomEditOperations(domEditSelection, operations, {
2820
- label: "Edit layer style",
2821
- skipRefresh: true,
2822
- prepareContent: importedFont
2823
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2824
- : undefined,
2825
- });
3090
+ try {
3091
+ await persistDomEditOperations(domEditSelection, operations, {
3092
+ label: "Edit layer style",
3093
+ skipRefresh: true,
3094
+ prepareContent: importedFont
3095
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
3096
+ : undefined,
3097
+ });
3098
+ } catch (err) {
3099
+ console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
3100
+ }
3101
+ refreshDomEditSelectionFromPreview(domEditSelection);
2826
3102
  },
2827
- [activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
3103
+ [
3104
+ activeCompPath,
3105
+ domEditSelection,
3106
+ persistDomEditOperations,
3107
+ refreshDomEditSelectionFromPreview,
3108
+ resolveImportedFontAsset,
3109
+ ],
2828
3110
  );
2829
3111
 
2830
3112
  const handleDomTextCommit = useCallback(
@@ -3023,6 +3305,8 @@ export function StudioApp() {
3023
3305
  const handleAskAgent = useCallback(() => {
3024
3306
  if (!domEditSelection) return;
3025
3307
  setAgentPromptTagSnippet(undefined);
3308
+ setAgentPromptSelectionContext(undefined);
3309
+ setAgentModalAnchorPoint(null);
3026
3310
  void preloadAgentPromptSnippet(domEditSelection);
3027
3311
  setAgentModalOpen(true);
3028
3312
  }, [domEditSelection, preloadAgentPromptSnippet]);
@@ -3037,6 +3321,7 @@ export function StudioApp() {
3037
3321
  selection: domEditSelection,
3038
3322
  currentTime,
3039
3323
  tagSnippet,
3324
+ selectionContext: agentPromptSelectionContext,
3040
3325
  userInstruction,
3041
3326
  sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3042
3327
  });
@@ -3048,11 +3333,21 @@ export function StudioApp() {
3048
3333
  }
3049
3334
 
3050
3335
  setAgentModalOpen(false);
3336
+ setAgentPromptSelectionContext(undefined);
3337
+ setAgentModalAnchorPoint(null);
3051
3338
  if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3052
3339
  setCopiedAgentPrompt(true);
3053
3340
  copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3054
3341
  },
3055
- [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
3342
+ [
3343
+ activeCompPath,
3344
+ agentPromptSelectionContext,
3345
+ agentPromptTagSnippet,
3346
+ currentTime,
3347
+ domEditSelection,
3348
+ projectDir,
3349
+ showToast,
3350
+ ],
3056
3351
  );
3057
3352
 
3058
3353
  const handlePreviewIframeRef = useCallback(
@@ -3070,9 +3365,9 @@ export function StudioApp() {
3070
3365
 
3071
3366
  const handlePreviewCanvasMouseDown = useCallback(
3072
3367
  (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3073
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
3368
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
3074
3369
  const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3075
- preferClipAncestor: options?.preferClipAncestor ?? true,
3370
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3076
3371
  });
3077
3372
  if (!nextSelection) {
3078
3373
  if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
@@ -3080,14 +3375,35 @@ export function StudioApp() {
3080
3375
  }
3081
3376
  e.preventDefault();
3082
3377
  e.stopPropagation();
3378
+ const localPointer = previewIframeRef.current
3379
+ ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
3380
+ : null;
3083
3381
  applyDomSelection(nextSelection, { additive: e.shiftKey });
3382
+ if (
3383
+ !e.shiftKey &&
3384
+ localPointer &&
3385
+ isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
3386
+ ) {
3387
+ setAgentPromptSelectionContext(
3388
+ buildRasterClickSelectionContext(nextSelection, localPointer),
3389
+ );
3390
+ setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
3391
+ void preloadAgentPromptSnippet(nextSelection);
3392
+ setAgentModalOpen(true);
3393
+ }
3084
3394
  },
3085
- [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
3395
+ [
3396
+ applyDomSelection,
3397
+ captionEditMode,
3398
+ compositionLoading,
3399
+ preloadAgentPromptSnippet,
3400
+ resolveDomSelectionFromPreviewPoint,
3401
+ ],
3086
3402
  );
3087
3403
 
3088
3404
  const handlePreviewCanvasPointerMove = useCallback(
3089
3405
  (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3090
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
3406
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
3091
3407
  updateDomEditHoverSelection(null);
3092
3408
  return null;
3093
3409
  }
@@ -3098,7 +3414,12 @@ export function StudioApp() {
3098
3414
  updateDomEditHoverSelection(nextSelection);
3099
3415
  return nextSelection;
3100
3416
  },
3101
- [captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
3417
+ [
3418
+ captionEditMode,
3419
+ compositionLoading,
3420
+ resolveDomSelectionFromPreviewPoint,
3421
+ updateDomEditHoverSelection,
3422
+ ],
3102
3423
  );
3103
3424
 
3104
3425
  const handlePreviewCanvasPointerLeave = useCallback(() => {
@@ -3397,6 +3718,7 @@ export function StudioApp() {
3397
3718
  }),
3398
3719
  );
3399
3720
 
3721
+ domEditSaveTimestampRef.current = Date.now();
3400
3722
  await saveProjectFilesWithHistory({
3401
3723
  projectId: pid,
3402
3724
  label: "Add timeline asset",
@@ -3884,6 +4206,7 @@ export function StudioApp() {
3884
4206
  </div>
3885
4207
  ) : (
3886
4208
  <LeftSidebar
4209
+ ref={leftSidebarRef}
3887
4210
  width={leftWidth}
3888
4211
  projectId={projectId}
3889
4212
  compositions={compositions}
@@ -3965,9 +4288,8 @@ export function StudioApp() {
3965
4288
  onInspectTimelineElement={handleTimelineElementInspect}
3966
4289
  inspectedTimelineElementId={inspectedTimelineElementId}
3967
4290
  timelineLayerChildCounts={timelineLayerChildCounts}
3968
- thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
3969
- onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
3970
4291
  onCompIdToSrcChange={setCompIdToSrc}
4292
+ onCompositionLoadingChange={setCompositionLoading}
3971
4293
  onCompositionChange={(compPath) => {
3972
4294
  // Sync activeCompPath when user drills down via timeline double-click
3973
4295
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
@@ -3984,7 +4306,7 @@ export function StudioApp() {
3984
4306
  iframeRef={previewIframeRef}
3985
4307
  activeCompositionPath={activeCompPath}
3986
4308
  hoverSelection={
3987
- STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
4309
+ STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
3988
4310
  ? domEditHoverSelection
3989
4311
  : null
3990
4312
  }
@@ -4093,6 +4415,7 @@ export function StudioApp() {
4093
4415
  projectId={projectId}
4094
4416
  assets={assets}
4095
4417
  element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4418
+ multiSelectCount={domEditGroupSelections.length}
4096
4419
  copiedAgentPrompt={copiedAgentPrompt}
4097
4420
  onClearSelection={clearDomSelection}
4098
4421
  onSetStyle={handleDomStyleCommit}
@@ -4122,9 +4445,9 @@ export function StudioApp() {
4122
4445
  projectId={projectId}
4123
4446
  onDelete={renderQueue.deleteRender}
4124
4447
  onClearCompleted={renderQueue.clearCompleted}
4125
- onStartRender={async (format, quality) => {
4448
+ onStartRender={async (format, quality, resolution, fps) => {
4126
4449
  await waitForPendingDomEditSaves();
4127
- await renderQueue.startRender(30, quality, format);
4450
+ await renderQueue.startRender({ fps, quality, format, resolution });
4128
4451
  }}
4129
4452
  isRendering={renderQueue.isRendering}
4130
4453
  />
@@ -4155,8 +4478,13 @@ export function StudioApp() {
4155
4478
  {agentModalOpen && domEditSelection && (
4156
4479
  <AskAgentModal
4157
4480
  selectionLabel={domEditSelection.label}
4481
+ anchorPoint={agentModalAnchorPoint}
4158
4482
  onSubmit={handleAgentModalSubmit}
4159
- onClose={() => setAgentModalOpen(false)}
4483
+ onClose={() => {
4484
+ setAgentModalOpen(false);
4485
+ setAgentPromptSelectionContext(undefined);
4486
+ setAgentModalAnchorPoint(null);
4487
+ }}
4160
4488
  />
4161
4489
  )}
4162
4490