@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.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 +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect } from "react";
2
+ import { useCaptionStore } from "../captions/store";
3
+ import { useCaptionSync } from "../captions/hooks/useCaptionSync";
4
+ import { parseCaptionComposition } from "../captions/parser";
5
+
6
+ interface UseCaptionDetectionParams {
7
+ projectId: string | null;
8
+ activeCompPath: string | null;
9
+ compIdToSrc: Map<string, string>;
10
+ captionEditMode: boolean;
11
+ captionHasSelection: boolean;
12
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
13
+ captionSync: ReturnType<typeof useCaptionSync>;
14
+ setRightCollapsed: (collapsed: boolean) => void;
15
+ }
16
+
17
+ export function useCaptionDetection({
18
+ projectId,
19
+ activeCompPath,
20
+ compIdToSrc,
21
+ captionEditMode,
22
+ captionHasSelection,
23
+ previewIframeRef,
24
+ captionSync,
25
+ setRightCollapsed,
26
+ }: UseCaptionDetectionParams) {
27
+ // eslint-disable-next-line no-restricted-syntax
28
+ useEffect(() => {
29
+ if (!projectId) return;
30
+
31
+ let activating = false;
32
+
33
+ const tryActivateCaptions = () => {
34
+ if (useCaptionStore.getState().isEditMode || activating) {
35
+ return;
36
+ }
37
+
38
+ const iframe = previewIframeRef.current;
39
+ let doc: Document | null = null;
40
+ let win: Window | null = null;
41
+ try {
42
+ doc = iframe?.contentDocument ?? null;
43
+ win = iframe?.contentWindow ?? null;
44
+ } catch {
45
+ return;
46
+ }
47
+ if (!doc || !win) return;
48
+
49
+ const groups = doc.querySelectorAll(".caption-group");
50
+ if (groups.length === 0) return;
51
+
52
+ let captionSrcPath: string | null = null;
53
+
54
+ const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
55
+ for (const host of compHosts) {
56
+ const src =
57
+ host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
58
+ if (src && src.includes("captions")) {
59
+ captionSrcPath = src;
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (!captionSrcPath) {
65
+ for (const [id, src] of compIdToSrc) {
66
+ if (id.includes("caption") || src.includes("caption")) {
67
+ captionSrcPath = src;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+
73
+ if (!captionSrcPath && activeCompPath?.includes("captions")) {
74
+ captionSrcPath = activeCompPath;
75
+ }
76
+
77
+ if (!captionSrcPath) {
78
+ const captionComp = doc.querySelector('[data-composition-id*="caption"]');
79
+ if (captionComp) {
80
+ const compId = captionComp.getAttribute("data-composition-id") || "";
81
+ captionSrcPath = compIdToSrc.get(compId) || null;
82
+ }
83
+ }
84
+
85
+ if (!captionSrcPath) return;
86
+
87
+ activating = true;
88
+ const srcPath = captionSrcPath;
89
+ fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
90
+ .then((r) => r.json())
91
+ .then((data: { content?: string }) => {
92
+ if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
93
+ const root = doc.querySelector("[data-composition-id]");
94
+ const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
95
+ const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
96
+ const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
97
+ const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
98
+ if (!model) return;
99
+ const store = useCaptionStore.getState();
100
+ store.setModel(model);
101
+ store.setSourceFilePath(srcPath);
102
+ store.setEditMode(true);
103
+ captionSync.loadOverrides();
104
+ })
105
+ .catch(() => {})
106
+ .finally(() => {
107
+ activating = false;
108
+ });
109
+ };
110
+
111
+ const handleMessage = (e: MessageEvent) => {
112
+ const data = e.data;
113
+ if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
114
+ tryActivateCaptions();
115
+ }
116
+ };
117
+
118
+ window.addEventListener("message", handleMessage);
119
+ tryActivateCaptions();
120
+
121
+ return () => {
122
+ window.removeEventListener("message", handleMessage);
123
+ };
124
+ }, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef]);
125
+
126
+ // eslint-disable-next-line no-restricted-syntax
127
+ useEffect(() => {
128
+ if (captionEditMode) {
129
+ setRightCollapsed(!captionHasSelection);
130
+ }
131
+ }, [captionHasSelection, captionEditMode, setRightCollapsed]);
132
+ }
@@ -0,0 +1,25 @@
1
+ import { useState } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+ import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
+
5
+ export function useCompositionDimensions() {
6
+ const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
7
+ null,
8
+ );
9
+
10
+ useMountEffect(() => {
11
+ const handleMessage = (e: MessageEvent) => {
12
+ const data = e.data;
13
+ if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
14
+ const { width, height } = data as { width: number; height: number };
15
+ if (!(width > 0) || !(height > 0)) return;
16
+ setCompositionDimensions((prev) =>
17
+ prev && prev.width === width && prev.height === height ? prev : { width, height },
18
+ );
19
+ };
20
+ window.addEventListener("message", handleMessage);
21
+ return () => window.removeEventListener("message", handleMessage);
22
+ });
23
+
24
+ return compositionDimensions;
25
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { LintFinding } from "../components/LintModal";
3
+
4
+ /**
5
+ * Captures `console.error` and `window.onerror` events from a preview iframe
6
+ * and exposes them as LintFinding[] for the console errors modal.
7
+ */
8
+ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null) {
9
+ const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
10
+ const consoleErrorsRef = useRef<LintFinding[]>([]);
11
+
12
+ const resetErrors = () => {
13
+ consoleErrorsRef.current = [];
14
+ setConsoleErrors(null);
15
+ };
16
+
17
+ // eslint-disable-next-line no-restricted-syntax
18
+ useEffect(() => {
19
+ if (!previewIframe) return;
20
+ const attachErrorCapture = () => {
21
+ try {
22
+ const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
23
+ if (!win) return;
24
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
25
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
26
+ const origError = win.console.error.bind(win.console);
27
+ win.console.error = function (...args: unknown[]) {
28
+ origError(...args);
29
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
30
+ if (text.includes("favicon")) return;
31
+ consoleErrorsRef.current = [
32
+ ...consoleErrorsRef.current,
33
+ { severity: "error", message: text },
34
+ ];
35
+ setConsoleErrors([...consoleErrorsRef.current]);
36
+ };
37
+ win.addEventListener("error", (e: ErrorEvent) => {
38
+ const text = e.message || String(e);
39
+ consoleErrorsRef.current = [
40
+ ...consoleErrorsRef.current,
41
+ { severity: "error", message: text },
42
+ ];
43
+ setConsoleErrors([...consoleErrorsRef.current]);
44
+ });
45
+ } catch {
46
+ /* same-origin only */
47
+ }
48
+ };
49
+ attachErrorCapture();
50
+ const handleLoad = () => {
51
+ consoleErrorsRef.current = [];
52
+ setConsoleErrors(null);
53
+ attachErrorCapture();
54
+ };
55
+ previewIframe.addEventListener("load", handleLoad);
56
+ return () => previewIframe.removeEventListener("load", handleLoad);
57
+ }, [previewIframe]);
58
+
59
+ return { consoleErrors, setConsoleErrors, resetErrors };
60
+ }