@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.
- package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- 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
|
+
}
|