@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.
- package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- 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 +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -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/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -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/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- 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/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,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
|
+
}
|