@hyperframes/studio 0.6.0-alpha.9 → 0.6.0

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 (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +33 -2
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-DYCiFGWQ.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,68 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import type { usePanelLayout } from "../hooks/usePanelLayout";
3
+
4
+ type PanelLayoutValue = ReturnType<typeof usePanelLayout>;
5
+
6
+ const PanelLayoutContext = createContext<PanelLayoutValue | null>(null);
7
+
8
+ export function usePanelLayoutContext(): PanelLayoutValue {
9
+ const ctx = useContext(PanelLayoutContext);
10
+ if (!ctx) throw new Error("usePanelLayoutContext must be used within PanelLayoutProvider");
11
+ return ctx;
12
+ }
13
+
14
+ export function PanelLayoutProvider({
15
+ value: {
16
+ leftWidth,
17
+ setLeftWidth,
18
+ rightWidth,
19
+ leftCollapsed,
20
+ setLeftCollapsed,
21
+ rightCollapsed,
22
+ setRightCollapsed,
23
+ rightPanelTab,
24
+ setRightPanelTab,
25
+ toggleLeftSidebar,
26
+ handlePanelResizeStart,
27
+ handlePanelResizeMove,
28
+ handlePanelResizeEnd,
29
+ },
30
+ children,
31
+ }: {
32
+ value: PanelLayoutValue;
33
+ children: ReactNode;
34
+ }) {
35
+ const stable = useMemo<PanelLayoutValue>(
36
+ () => ({
37
+ leftWidth,
38
+ setLeftWidth,
39
+ rightWidth,
40
+ leftCollapsed,
41
+ setLeftCollapsed,
42
+ rightCollapsed,
43
+ setRightCollapsed,
44
+ rightPanelTab,
45
+ setRightPanelTab,
46
+ toggleLeftSidebar,
47
+ handlePanelResizeStart,
48
+ handlePanelResizeMove,
49
+ handlePanelResizeEnd,
50
+ }),
51
+ [
52
+ leftWidth,
53
+ setLeftWidth,
54
+ rightWidth,
55
+ leftCollapsed,
56
+ setLeftCollapsed,
57
+ rightCollapsed,
58
+ setRightCollapsed,
59
+ rightPanelTab,
60
+ setRightPanelTab,
61
+ toggleLeftSidebar,
62
+ handlePanelResizeStart,
63
+ handlePanelResizeMove,
64
+ handlePanelResizeEnd,
65
+ ],
66
+ );
67
+ return <PanelLayoutContext value={stable}>{children}</PanelLayoutContext>;
68
+ }
@@ -0,0 +1,135 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
+
5
+ export interface StudioContextValue {
6
+ projectId: string;
7
+ activeCompPath: string | null;
8
+ setActiveCompPath: (path: string | null) => void;
9
+ showToast: (message: string, tone?: "error" | "info") => void;
10
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
11
+ captionEditMode: boolean;
12
+ compositionLoading: boolean;
13
+ refreshKey: number;
14
+ setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
15
+ currentTime: number;
16
+ timelineElements: TimelineElement[];
17
+ isPlaying: boolean;
18
+ editHistory: {
19
+ canUndo: boolean;
20
+ canRedo: boolean;
21
+ undoLabel: string | undefined;
22
+ redoLabel: string | undefined;
23
+ };
24
+ handleUndo: () => Promise<void>;
25
+ handleRedo: () => Promise<void>;
26
+ renderQueue: {
27
+ jobs: unknown[];
28
+ isRendering: boolean;
29
+ deleteRender: (jobId: string) => void;
30
+ clearCompleted: () => void;
31
+ startRender: (options: unknown) => Promise<void>;
32
+ };
33
+ compositionDimensions: CompositionDimensions | null;
34
+ waitForPendingDomEditSaves: () => Promise<void>;
35
+ handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
36
+ refreshPreviewDocumentVersion: () => void;
37
+ timelineVisible: boolean;
38
+ toggleTimelineVisibility: () => void;
39
+ }
40
+
41
+ const StudioContext = createContext<StudioContextValue | null>(null);
42
+
43
+ export function useStudioContext(): StudioContextValue {
44
+ const ctx = useContext(StudioContext);
45
+ if (!ctx) throw new Error("useStudioContext must be used within StudioProvider");
46
+ return ctx;
47
+ }
48
+
49
+ export function StudioProvider({
50
+ value,
51
+ children,
52
+ }: {
53
+ value: StudioContextValue;
54
+ children: ReactNode;
55
+ }) {
56
+ const {
57
+ projectId,
58
+ activeCompPath,
59
+ setActiveCompPath,
60
+ showToast,
61
+ previewIframeRef,
62
+ captionEditMode,
63
+ compositionLoading,
64
+ refreshKey,
65
+ setRefreshKey,
66
+ currentTime,
67
+ timelineElements,
68
+ isPlaying,
69
+ editHistory,
70
+ handleUndo,
71
+ handleRedo,
72
+ renderQueue,
73
+ compositionDimensions,
74
+ waitForPendingDomEditSaves,
75
+ handlePreviewIframeRef,
76
+ refreshPreviewDocumentVersion,
77
+ timelineVisible,
78
+ toggleTimelineVisibility,
79
+ } = value;
80
+
81
+ const stable = useMemo<StudioContextValue>(
82
+ () => ({
83
+ projectId,
84
+ activeCompPath,
85
+ setActiveCompPath,
86
+ showToast,
87
+ previewIframeRef,
88
+ captionEditMode,
89
+ compositionLoading,
90
+ refreshKey,
91
+ setRefreshKey,
92
+ currentTime,
93
+ timelineElements,
94
+ isPlaying,
95
+ editHistory,
96
+ handleUndo,
97
+ handleRedo,
98
+ renderQueue,
99
+ compositionDimensions,
100
+ waitForPendingDomEditSaves,
101
+ handlePreviewIframeRef,
102
+ refreshPreviewDocumentVersion,
103
+ timelineVisible,
104
+ toggleTimelineVisibility,
105
+ }),
106
+ // Representative subset of deps that actually change — stable callbacks
107
+ // (showToast, setActiveCompPath, etc.) are included for correctness but
108
+ // won't trigger re-renders on their own.
109
+ [
110
+ projectId,
111
+ activeCompPath,
112
+ captionEditMode,
113
+ compositionLoading,
114
+ refreshKey,
115
+ currentTime,
116
+ isPlaying,
117
+ compositionDimensions,
118
+ timelineVisible,
119
+ editHistory,
120
+ timelineElements,
121
+ renderQueue,
122
+ setActiveCompPath,
123
+ showToast,
124
+ previewIframeRef,
125
+ setRefreshKey,
126
+ handleUndo,
127
+ handleRedo,
128
+ waitForPendingDomEditSaves,
129
+ handlePreviewIframeRef,
130
+ refreshPreviewDocumentVersion,
131
+ toggleTimelineVisibility,
132
+ ],
133
+ );
134
+ return <StudioContext value={stable}>{children}</StudioContext>;
135
+ }
@@ -0,0 +1,326 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { usePlayerStore } from "../player";
3
+ import type { TimelineElement } from "../player";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
+ import { STUDIO_MANUAL_EDITS_PATH } from "../components/editor/manualEdits";
7
+ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
+ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
9
+ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
10
+
11
+ // ── Types ──
12
+
13
+ interface EditHistoryHandle {
14
+ undo: (callbacks: {
15
+ readFile: (path: string) => Promise<string>;
16
+ writeFile: (path: string, content: string) => Promise<void>;
17
+ }) => Promise<{
18
+ ok: boolean;
19
+ reason?: string;
20
+ label?: string;
21
+ paths?: string[];
22
+ }>;
23
+ redo: (callbacks: {
24
+ readFile: (path: string) => Promise<string>;
25
+ writeFile: (path: string, content: string) => Promise<void>;
26
+ }) => Promise<{
27
+ ok: boolean;
28
+ reason?: string;
29
+ label?: string;
30
+ paths?: string[];
31
+ }>;
32
+ }
33
+
34
+ interface UseAppHotkeysParams {
35
+ toggleTimelineVisibility: () => void;
36
+ handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
37
+ handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
38
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
39
+ clearDomSelectionRef: React.MutableRefObject<() => void>;
40
+ editHistory: EditHistoryHandle;
41
+ readOptionalProjectFile: (path: string) => Promise<string>;
42
+ readProjectFile: (path: string) => Promise<string>;
43
+ writeProjectFile: (path: string, content: string) => Promise<void>;
44
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
45
+ showToast: (message: string, tone?: "error" | "info") => void;
46
+ syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
47
+ waitForPendingDomEditSaves: () => Promise<void>;
48
+ leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
49
+ }
50
+
51
+ // ── Hook ──
52
+
53
+ export function useAppHotkeys({
54
+ toggleTimelineVisibility,
55
+ handleTimelineElementDelete,
56
+ handleDomEditElementDelete,
57
+ domEditSelectionRef,
58
+ clearDomSelectionRef,
59
+ editHistory,
60
+ readOptionalProjectFile,
61
+ readProjectFile,
62
+ writeProjectFile,
63
+ domEditSaveTimestampRef,
64
+ showToast,
65
+ syncHistoryPreviewAfterApply,
66
+ waitForPendingDomEditSaves,
67
+ leftSidebarRef,
68
+ }: UseAppHotkeysParams) {
69
+ const previewHotkeyWindowRef = useRef<Window | null>(null);
70
+ const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
71
+ const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
72
+
73
+ // ── Timeline toggle hotkey ──
74
+
75
+ const handleTimelineToggleHotkey = useCallback(
76
+ (event: KeyboardEvent) => {
77
+ if (!shouldHandleTimelineToggleHotkey(event)) return;
78
+ event.preventDefault();
79
+ toggleTimelineVisibility();
80
+ },
81
+ [toggleTimelineVisibility],
82
+ );
83
+
84
+ // ── History file read/write helpers ──
85
+
86
+ const readHistoryProjectFile = useCallback(
87
+ async (path: string): Promise<string> => {
88
+ return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
89
+ ? readOptionalProjectFile(path)
90
+ : readProjectFile(path);
91
+ },
92
+ [readOptionalProjectFile, readProjectFile],
93
+ );
94
+
95
+ const writeHistoryProjectFile = useCallback(
96
+ async (path: string, content: string): Promise<void> => {
97
+ domEditSaveTimestampRef.current = Date.now();
98
+ await writeProjectFile(path, content);
99
+ },
100
+ [domEditSaveTimestampRef, writeProjectFile],
101
+ );
102
+
103
+ // ── Undo / Redo ──
104
+
105
+ const handleUndo = useCallback(async () => {
106
+ await waitForPendingDomEditSaves();
107
+ const result = await editHistory.undo({
108
+ readFile: readHistoryProjectFile,
109
+ writeFile: writeHistoryProjectFile,
110
+ });
111
+ if (!result.ok && result.reason === "content-mismatch") {
112
+ showToast("File changed outside Studio. Undo history was not applied.", "info");
113
+ return;
114
+ }
115
+ if (result.ok && result.label) {
116
+ clearDomSelectionRef.current();
117
+ await syncHistoryPreviewAfterApply(result.paths);
118
+ showToast(`Undid ${result.label}`, "info");
119
+ }
120
+ }, [
121
+ clearDomSelectionRef,
122
+ editHistory,
123
+ readHistoryProjectFile,
124
+ showToast,
125
+ syncHistoryPreviewAfterApply,
126
+ waitForPendingDomEditSaves,
127
+ writeHistoryProjectFile,
128
+ ]);
129
+
130
+ const handleRedo = useCallback(async () => {
131
+ await waitForPendingDomEditSaves();
132
+ const result = await editHistory.redo({
133
+ readFile: readHistoryProjectFile,
134
+ writeFile: writeHistoryProjectFile,
135
+ });
136
+ if (!result.ok && result.reason === "content-mismatch") {
137
+ showToast("File changed outside Studio. Redo history was not applied.", "info");
138
+ return;
139
+ }
140
+ if (result.ok && result.label) {
141
+ clearDomSelectionRef.current();
142
+ await syncHistoryPreviewAfterApply(result.paths);
143
+ showToast(`Redid ${result.label}`, "info");
144
+ }
145
+ }, [
146
+ clearDomSelectionRef,
147
+ editHistory,
148
+ readHistoryProjectFile,
149
+ showToast,
150
+ syncHistoryPreviewAfterApply,
151
+ waitForPendingDomEditSaves,
152
+ writeHistoryProjectFile,
153
+ ]);
154
+
155
+ // ── Stable refs for the consolidated keydown handler ──
156
+
157
+ const handleToggleRef = useRef(handleTimelineToggleHotkey);
158
+ handleToggleRef.current = handleTimelineToggleHotkey;
159
+ const handleDeleteRef = useRef(handleTimelineElementDelete);
160
+ handleDeleteRef.current = handleTimelineElementDelete;
161
+ const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
162
+ handleDomEditDeleteRef.current = handleDomEditElementDelete;
163
+ const handleUndoRef = useRef(handleUndo);
164
+ handleUndoRef.current = handleUndo;
165
+ const handleRedoRef = useRef(handleRedo);
166
+ handleRedoRef.current = handleRedo;
167
+
168
+ // ── Consolidated keydown handler ──
169
+
170
+ handleAppKeyDownRef.current = (event: KeyboardEvent) => {
171
+ // Shift+T — toggle timeline
172
+ handleToggleRef.current(event);
173
+
174
+ // Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
175
+ if (event.metaKey || event.ctrlKey) {
176
+ if (!shouldIgnoreHistoryShortcut(event.target)) {
177
+ const key = event.key.toLowerCase();
178
+ if (key === "z" && !event.shiftKey) {
179
+ event.preventDefault();
180
+ void handleUndoRef.current();
181
+ return;
182
+ }
183
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
184
+ event.preventDefault();
185
+ void handleRedoRef.current();
186
+ return;
187
+ }
188
+ }
189
+
190
+ // Cmd/Ctrl+1 — sidebar: Compositions tab
191
+ if (event.key === "1") {
192
+ event.preventDefault();
193
+ leftSidebarRef.current?.selectTab("compositions");
194
+ return;
195
+ }
196
+
197
+ // Cmd/Ctrl+2 — sidebar: Assets tab
198
+ if (event.key === "2") {
199
+ event.preventDefault();
200
+ leftSidebarRef.current?.selectTab("assets");
201
+ return;
202
+ }
203
+ }
204
+
205
+ // Delete / Backspace — remove selected element (timeline clip or preview selection)
206
+ if (
207
+ (event.key === "Delete" || event.key === "Backspace") &&
208
+ !event.metaKey &&
209
+ !event.ctrlKey &&
210
+ !event.altKey &&
211
+ !isEditableTarget(event.target)
212
+ ) {
213
+ const { selectedElementId, elements } = usePlayerStore.getState();
214
+ if (selectedElementId) {
215
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
216
+ if (element) {
217
+ event.preventDefault();
218
+ void handleDeleteRef.current(element);
219
+ return;
220
+ }
221
+ }
222
+ const domSelection = domEditSelectionRef.current;
223
+ if (domSelection) {
224
+ event.preventDefault();
225
+ void handleDomEditDeleteRef.current(domSelection);
226
+ }
227
+ }
228
+ };
229
+
230
+ // ── Window keydown listener ──
231
+
232
+ // eslint-disable-next-line no-restricted-syntax
233
+ useEffect(() => {
234
+ function handleAppKeyDown(event: KeyboardEvent) {
235
+ handleAppKeyDownRef.current?.(event);
236
+ }
237
+ window.addEventListener("keydown", handleAppKeyDown, true);
238
+ return () => window.removeEventListener("keydown", handleAppKeyDown, true);
239
+ }, []);
240
+
241
+ // ── Preview iframe keydown forwarding ──
242
+
243
+ const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
244
+ handleAppKeyDownRef.current?.(event);
245
+ }, []);
246
+
247
+ const syncPreviewTimelineHotkey = useCallback(
248
+ (iframe: HTMLIFrameElement | null) => {
249
+ const nextWindow = iframe?.contentWindow ?? null;
250
+ if (previewHotkeyWindowRef.current === nextWindow) return;
251
+ if (previewHotkeyWindowRef.current) {
252
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
253
+ }
254
+ previewHotkeyWindowRef.current = nextWindow;
255
+ nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
256
+ },
257
+ [previewAppKeyDownHandler],
258
+ );
259
+
260
+ useEffect(
261
+ () => () => {
262
+ if (previewHotkeyWindowRef.current) {
263
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
264
+ previewHotkeyWindowRef.current = null;
265
+ }
266
+ },
267
+ [previewAppKeyDownHandler],
268
+ );
269
+
270
+ // ── History hotkey for iframe forwarding ──
271
+
272
+ const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
273
+ if (!(event.metaKey || event.ctrlKey)) return;
274
+ if (shouldIgnoreHistoryShortcut(event.target)) return;
275
+ const key = event.key.toLowerCase();
276
+ if (key === "z" && !event.shiftKey) {
277
+ event.preventDefault();
278
+ void handleUndoRef.current();
279
+ return;
280
+ }
281
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
282
+ event.preventDefault();
283
+ void handleRedoRef.current();
284
+ }
285
+ }, []);
286
+
287
+ const syncPreviewHistoryHotkey = useCallback(
288
+ (iframe: HTMLIFrameElement | null) => {
289
+ previewHistoryHotkeyCleanupRef.current?.();
290
+ previewHistoryHotkeyCleanupRef.current = null;
291
+
292
+ const win = iframe?.contentWindow ?? null;
293
+ let doc: Document | null = null;
294
+ try {
295
+ doc = iframe?.contentDocument ?? null;
296
+ } catch {
297
+ doc = null;
298
+ }
299
+ if (!win && !doc) return;
300
+
301
+ win?.addEventListener("keydown", handleHistoryHotkey, true);
302
+ doc?.addEventListener("keydown", handleHistoryHotkey, true);
303
+ previewHistoryHotkeyCleanupRef.current = () => {
304
+ win?.removeEventListener("keydown", handleHistoryHotkey, true);
305
+ doc?.removeEventListener("keydown", handleHistoryHotkey, true);
306
+ };
307
+ },
308
+ [handleHistoryHotkey],
309
+ );
310
+
311
+ useEffect(
312
+ () => () => {
313
+ previewHistoryHotkeyCleanupRef.current?.();
314
+ previewHistoryHotkeyCleanupRef.current = null;
315
+ },
316
+ [],
317
+ );
318
+
319
+ return {
320
+ handleUndo,
321
+ handleRedo,
322
+ syncPreviewTimelineHotkey,
323
+ syncPreviewHistoryHotkey,
324
+ handleTimelineToggleHotkey,
325
+ };
326
+ }
@@ -0,0 +1,162 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { copyTextToClipboard } from "../utils/clipboard";
3
+ import { readTagSnippetByTarget } from "../utils/sourcePatcher";
4
+ import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
5
+ import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
6
+
7
+ // ── Types ──
8
+
9
+ export interface UseAskAgentModalParams {
10
+ projectId: string | null;
11
+ activeCompPath: string | null;
12
+ projectDir: string | null;
13
+ projectIdRef: React.MutableRefObject<string | null>;
14
+ currentTime: number;
15
+ showToast: (message: string, tone?: "error" | "info") => void;
16
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
17
+ domEditSelection: DomEditSelection | null;
18
+ }
19
+
20
+ // ── Hook ──
21
+
22
+ export function useAskAgentModal({
23
+ activeCompPath,
24
+ projectDir,
25
+ projectIdRef,
26
+ currentTime,
27
+ showToast,
28
+ domEditSelectionRef,
29
+ domEditSelection,
30
+ }: UseAskAgentModalParams) {
31
+ // ── State ──
32
+
33
+ const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
34
+ const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
35
+ string | undefined
36
+ >();
37
+ const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
38
+ null,
39
+ );
40
+ const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
41
+ const [agentModalOpen, setAgentModalOpen] = useState(false);
42
+
43
+ // ── Refs ──
44
+
45
+ const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
46
+
47
+ // ── Callbacks ──
48
+
49
+ const preloadAgentPromptSnippet = useCallback(
50
+ async (selection: DomEditSelection) => {
51
+ const pid = projectIdRef.current;
52
+ if (!pid) return;
53
+
54
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
55
+ try {
56
+ const response = await fetch(
57
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
58
+ );
59
+ if (!response.ok) return;
60
+
61
+ const data = (await response.json()) as { content?: string };
62
+ const html = data.content;
63
+ const tagSnippet =
64
+ typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
65
+
66
+ setAgentPromptTagSnippet((current) => {
67
+ if (domEditSelectionRef.current !== selection) return current;
68
+ return tagSnippet;
69
+ });
70
+ } catch {
71
+ // Runtime outerHTML is still available as a synchronous copy fallback.
72
+ }
73
+ },
74
+ [activeCompPath, domEditSelectionRef, projectIdRef],
75
+ );
76
+
77
+ const handleAskAgent = useCallback(() => {
78
+ if (!domEditSelection) return;
79
+ setAgentPromptTagSnippet(undefined);
80
+ setAgentPromptSelectionContext(undefined);
81
+ setAgentModalAnchorPoint(null);
82
+ void preloadAgentPromptSnippet(domEditSelection);
83
+ setAgentModalOpen(true);
84
+ }, [domEditSelection, preloadAgentPromptSnippet]);
85
+
86
+ const handleAgentModalSubmit = useCallback(
87
+ async (userInstruction: string) => {
88
+ if (!domEditSelection) return;
89
+
90
+ const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
91
+ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
92
+ const prompt = buildElementAgentPrompt({
93
+ selection: domEditSelection,
94
+ currentTime,
95
+ tagSnippet,
96
+ selectionContext: agentPromptSelectionContext,
97
+ userInstruction,
98
+ sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
99
+ });
100
+
101
+ const copied = await copyTextToClipboard(prompt);
102
+ if (!copied) {
103
+ showToast("Could not copy prompt to clipboard.", "error");
104
+ return;
105
+ }
106
+
107
+ setAgentModalOpen(false);
108
+ setAgentPromptSelectionContext(undefined);
109
+ setAgentModalAnchorPoint(null);
110
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
111
+ setCopiedAgentPrompt(true);
112
+ copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
113
+ },
114
+ [
115
+ activeCompPath,
116
+ agentPromptSelectionContext,
117
+ agentPromptTagSnippet,
118
+ currentTime,
119
+ domEditSelection,
120
+ projectDir,
121
+ showToast,
122
+ ],
123
+ );
124
+
125
+ // ── Effects ──
126
+
127
+ // Clear agent-prompt state when selection changes
128
+ // eslint-disable-next-line no-restricted-syntax
129
+ useEffect(() => {
130
+ setAgentPromptTagSnippet(undefined);
131
+ setAgentPromptSelectionContext(undefined);
132
+ setAgentModalAnchorPoint(null);
133
+ setCopiedAgentPrompt(false);
134
+ }, [domEditSelection]);
135
+
136
+ // Cleanup copiedAgentTimerRef
137
+ // eslint-disable-next-line no-restricted-syntax
138
+ useEffect(
139
+ () => () => {
140
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
141
+ },
142
+ [],
143
+ );
144
+
145
+ return {
146
+ // State
147
+ agentModalOpen,
148
+ agentModalAnchorPoint,
149
+ copiedAgentPrompt,
150
+ agentPromptSelectionContext,
151
+
152
+ // Setters (consumed by handlePreviewCanvasMouseDown and other callers)
153
+ setAgentModalOpen,
154
+ setAgentPromptSelectionContext,
155
+ setAgentModalAnchorPoint,
156
+
157
+ // Callbacks
158
+ preloadAgentPromptSnippet,
159
+ handleAskAgent,
160
+ handleAgentModalSubmit,
161
+ };
162
+ }