@hyperframes/studio 0.1.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/index-B1830ANq.js +78 -0
- package/dist/assets/index-KoBceNoU.css +1 -0
- package/dist/icons/timeline/audio.svg +7 -0
- package/dist/icons/timeline/captions.svg +5 -0
- package/dist/icons/timeline/composition.svg +12 -0
- package/dist/icons/timeline/image.svg +18 -0
- package/dist/icons/timeline/music.svg +10 -0
- package/dist/icons/timeline/text.svg +3 -0
- package/dist/index.html +13 -0
- package/package.json +50 -0
- package/src/App.tsx +557 -0
- package/src/components/editor/FileTree.tsx +70 -0
- package/src/components/editor/PropertyPanel.tsx +209 -0
- package/src/components/editor/SourceEditor.tsx +116 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
- package/src/components/nle/NLELayout.tsx +252 -0
- package/src/components/nle/NLEPreview.tsx +37 -0
- package/src/components/ui/Button.tsx +123 -0
- package/src/components/ui/index.ts +2 -0
- package/src/hooks/useCodeEditor.ts +82 -0
- package/src/hooks/useElementPicker.ts +338 -0
- package/src/hooks/useMountEffect.ts +18 -0
- package/src/icons/SystemIcons.tsx +130 -0
- package/src/index.ts +31 -0
- package/src/main.tsx +10 -0
- package/src/player/components/AgentActivityTrack.tsx +98 -0
- package/src/player/components/Player.tsx +120 -0
- package/src/player/components/PlayerControls.tsx +181 -0
- package/src/player/components/PreviewPanel.tsx +149 -0
- package/src/player/components/Timeline.tsx +431 -0
- package/src/player/hooks/useTimelinePlayer.ts +465 -0
- package/src/player/index.ts +17 -0
- package/src/player/lib/time.ts +5 -0
- package/src/player/lib/useMountEffect.ts +10 -0
- package/src/player/store/playerStore.ts +93 -0
- package/src/styles/studio.css +31 -0
- package/src/utils/sourcePatcher.ts +149 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { X, MousePointer, Move, Type, Palette, Clock, Eye } from "../../icons/SystemIcons";
|
|
3
|
+
import { Button, IconButton } from "../ui";
|
|
4
|
+
import type { PickedElement } from "../../hooks/useElementPicker";
|
|
5
|
+
|
|
6
|
+
interface PropertyPanelProps {
|
|
7
|
+
element: PickedElement | null;
|
|
8
|
+
isPickMode: boolean;
|
|
9
|
+
onEnablePick: () => void;
|
|
10
|
+
onDisablePick: () => void;
|
|
11
|
+
onClearPick: () => void;
|
|
12
|
+
onSetStyle: (prop: string, value: string) => void;
|
|
13
|
+
onSetDataAttr: (attr: string, value: string) => void;
|
|
14
|
+
onSetText?: (text: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function PropertyRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center gap-2">
|
|
20
|
+
<span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
|
|
21
|
+
<input
|
|
22
|
+
type="text"
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(e) => onChange(e.target.value)}
|
|
25
|
+
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600 min-w-0"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
|
|
35
|
+
<div className="flex items-center gap-1 flex-1">
|
|
36
|
+
<div className="w-5 h-5 rounded border border-neutral-700 flex-shrink-0" style={{ backgroundColor: value }} />
|
|
37
|
+
<input
|
|
38
|
+
type="text"
|
|
39
|
+
value={value}
|
|
40
|
+
onChange={(e) => onChange(e.target.value)}
|
|
41
|
+
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600 min-w-0"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function SectionHeader({ icon: Icon, label }: { icon: typeof Move; label: string }) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-center gap-1.5 mt-2 mb-1">
|
|
51
|
+
<Icon size={10} className="text-neutral-600" />
|
|
52
|
+
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">{label}</span>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const PropertyPanel = memo(function PropertyPanel({
|
|
58
|
+
element,
|
|
59
|
+
isPickMode,
|
|
60
|
+
onEnablePick,
|
|
61
|
+
onDisablePick,
|
|
62
|
+
onClearPick,
|
|
63
|
+
onSetStyle,
|
|
64
|
+
onSetDataAttr,
|
|
65
|
+
onSetText,
|
|
66
|
+
}: PropertyPanelProps) {
|
|
67
|
+
if (!element) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col items-center justify-center h-full px-4 text-center">
|
|
70
|
+
<MousePointer size={20} className="text-neutral-700 mb-2" />
|
|
71
|
+
<p className="text-xs text-neutral-500">Click an element in the preview to inspect it</p>
|
|
72
|
+
<Button
|
|
73
|
+
variant="secondary"
|
|
74
|
+
size="sm"
|
|
75
|
+
onClick={isPickMode ? onDisablePick : onEnablePick}
|
|
76
|
+
className={`mt-3 ${isPickMode ? "bg-blue-500/20 text-blue-400 border-blue-500/30" : ""}`}
|
|
77
|
+
>
|
|
78
|
+
{isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
|
|
79
|
+
</Button>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const s = element.computedStyles;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex flex-col h-full min-h-0">
|
|
88
|
+
{/* Header */}
|
|
89
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 flex-shrink-0">
|
|
90
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
91
|
+
<span className="text-2xs font-mono text-blue-400 truncate">{element.selector}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="flex items-center gap-1">
|
|
94
|
+
<IconButton
|
|
95
|
+
icon={<MousePointer size={11} />}
|
|
96
|
+
aria-label={isPickMode ? "Disable pick mode" : "Enable pick mode"}
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={isPickMode ? onDisablePick : onEnablePick}
|
|
99
|
+
className={isPickMode ? "text-blue-400 bg-blue-500/10" : ""}
|
|
100
|
+
/>
|
|
101
|
+
<IconButton icon={<X size={11} />} aria-label="Clear selection" size="sm" onClick={onClearPick} />
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Properties */}
|
|
106
|
+
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
|
107
|
+
{/* Element info */}
|
|
108
|
+
<div className="flex items-center gap-2 mb-2">
|
|
109
|
+
<span className="text-2xs text-neutral-300 font-medium">{element.label}</span>
|
|
110
|
+
<span className="text-2xs text-neutral-600 font-mono"><{element.tagName}></span>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Position & Size */}
|
|
114
|
+
<SectionHeader icon={Move} label="Position & Size" />
|
|
115
|
+
<div className="grid grid-cols-2 gap-1">
|
|
116
|
+
<PropertyRow label="X" value={s["left"] ?? "auto"} onChange={(v) => onSetStyle("left", v)} />
|
|
117
|
+
<PropertyRow label="Y" value={s["top"] ?? "auto"} onChange={(v) => onSetStyle("top", v)} />
|
|
118
|
+
<PropertyRow label="W" value={s["width"] ?? "auto"} onChange={(v) => onSetStyle("width", v)} />
|
|
119
|
+
<PropertyRow label="H" value={s["height"] ?? "auto"} onChange={(v) => onSetStyle("height", v)} />
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Typography */}
|
|
123
|
+
{(element.tagName === "div" ||
|
|
124
|
+
element.tagName === "span" ||
|
|
125
|
+
element.tagName === "p" ||
|
|
126
|
+
element.tagName === "h1" ||
|
|
127
|
+
element.tagName === "h2") && (
|
|
128
|
+
<>
|
|
129
|
+
<SectionHeader icon={Type} label="Typography" />
|
|
130
|
+
<PropertyRow label="Size" value={s["font-size"] ?? ""} onChange={(v) => onSetStyle("font-size", v)} />
|
|
131
|
+
<PropertyRow label="Weight" value={s["font-weight"] ?? ""} onChange={(v) => onSetStyle("font-weight", v)} />
|
|
132
|
+
<PropertyRow
|
|
133
|
+
label="Family"
|
|
134
|
+
value={s["font-family"]?.split(",")[0] ?? ""}
|
|
135
|
+
onChange={(v) => onSetStyle("font-family", v)}
|
|
136
|
+
/>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Colors */}
|
|
141
|
+
<SectionHeader icon={Palette} label="Colors" />
|
|
142
|
+
<ColorRow label="Color" value={s["color"] ?? "#fff"} onChange={(v) => onSetStyle("color", v)} />
|
|
143
|
+
<ColorRow
|
|
144
|
+
label="Background"
|
|
145
|
+
value={s["background-color"] ?? "transparent"}
|
|
146
|
+
onChange={(v) => onSetStyle("background-color", v)}
|
|
147
|
+
/>
|
|
148
|
+
|
|
149
|
+
{/* Appearance */}
|
|
150
|
+
<SectionHeader icon={Eye} label="Appearance" />
|
|
151
|
+
<PropertyRow label="Opacity" value={s["opacity"] ?? "1"} onChange={(v) => onSetStyle("opacity", v)} />
|
|
152
|
+
<PropertyRow
|
|
153
|
+
label="Radius"
|
|
154
|
+
value={s["border-radius"] ?? "0"}
|
|
155
|
+
onChange={(v) => onSetStyle("border-radius", v)}
|
|
156
|
+
/>
|
|
157
|
+
<PropertyRow label="Z-index" value={s["z-index"] ?? "auto"} onChange={(v) => onSetStyle("z-index", v)} />
|
|
158
|
+
<PropertyRow label="Transform" value={s["transform"] ?? "none"} onChange={(v) => onSetStyle("transform", v)} />
|
|
159
|
+
|
|
160
|
+
{/* Timing */}
|
|
161
|
+
{(element.dataAttributes["start"] || element.dataAttributes["duration"]) && (
|
|
162
|
+
<>
|
|
163
|
+
<SectionHeader icon={Clock} label="Timing" />
|
|
164
|
+
{element.dataAttributes["start"] != null && (
|
|
165
|
+
<PropertyRow
|
|
166
|
+
label="Start"
|
|
167
|
+
value={element.dataAttributes["start"]}
|
|
168
|
+
onChange={(v) => onSetDataAttr("start", v)}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
{element.dataAttributes["duration"] != null && (
|
|
172
|
+
<PropertyRow
|
|
173
|
+
label="Duration"
|
|
174
|
+
value={element.dataAttributes["duration"]}
|
|
175
|
+
onChange={(v) => onSetDataAttr("duration", v)}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
{element.dataAttributes["track-index"] != null && (
|
|
179
|
+
<PropertyRow
|
|
180
|
+
label="Track"
|
|
181
|
+
value={element.dataAttributes["track-index"]}
|
|
182
|
+
onChange={(v) => onSetDataAttr("track-index", v)}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Editable text content */}
|
|
189
|
+
{element.textContent && (
|
|
190
|
+
<>
|
|
191
|
+
<SectionHeader icon={Type} label="Text Content" />
|
|
192
|
+
<textarea
|
|
193
|
+
defaultValue={element.textContent}
|
|
194
|
+
onBlur={(e) => onSetText?.(e.target.value)}
|
|
195
|
+
onKeyDown={(e) => {
|
|
196
|
+
if (e.key === "Enter" && e.metaKey) {
|
|
197
|
+
(e.target as HTMLTextAreaElement).blur();
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
rows={3}
|
|
201
|
+
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1.5 text-xs text-neutral-200 outline-none focus:border-neutral-600 resize-y leading-relaxed"
|
|
202
|
+
placeholder="Edit text..."
|
|
203
|
+
/>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useRef, useCallback, memo } from "react";
|
|
2
|
+
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
|
|
3
|
+
import { EditorState } from "@codemirror/state";
|
|
4
|
+
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|
5
|
+
import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
|
|
6
|
+
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
|
7
|
+
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
|
8
|
+
import { oneDark } from "@codemirror/theme-one-dark";
|
|
9
|
+
import { html } from "@codemirror/lang-html";
|
|
10
|
+
import { css } from "@codemirror/lang-css";
|
|
11
|
+
import { javascript } from "@codemirror/lang-javascript";
|
|
12
|
+
|
|
13
|
+
function getLanguageExtension(language: string) {
|
|
14
|
+
switch (language) {
|
|
15
|
+
case "html":
|
|
16
|
+
return html();
|
|
17
|
+
case "css":
|
|
18
|
+
return css();
|
|
19
|
+
case "javascript":
|
|
20
|
+
case "js":
|
|
21
|
+
case "typescript":
|
|
22
|
+
case "ts":
|
|
23
|
+
return javascript({ typescript: language === "typescript" || language === "ts" });
|
|
24
|
+
default:
|
|
25
|
+
return html();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function detectLanguage(filePath: string): string {
|
|
30
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
31
|
+
const map: Record<string, string> = {
|
|
32
|
+
html: "html",
|
|
33
|
+
htm: "html",
|
|
34
|
+
css: "css",
|
|
35
|
+
js: "javascript",
|
|
36
|
+
ts: "typescript",
|
|
37
|
+
jsx: "javascript",
|
|
38
|
+
tsx: "typescript",
|
|
39
|
+
json: "javascript",
|
|
40
|
+
};
|
|
41
|
+
return map[ext] ?? "html";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SourceEditorProps {
|
|
45
|
+
content: string;
|
|
46
|
+
filePath?: string;
|
|
47
|
+
language?: string;
|
|
48
|
+
onChange?: (content: string) => void;
|
|
49
|
+
readOnly?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const SourceEditor = memo(function SourceEditor({
|
|
53
|
+
content,
|
|
54
|
+
filePath,
|
|
55
|
+
language,
|
|
56
|
+
onChange,
|
|
57
|
+
readOnly = false,
|
|
58
|
+
}: SourceEditorProps) {
|
|
59
|
+
const editorRef = useRef<EditorView | null>(null);
|
|
60
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
61
|
+
const onChangeRef = useRef(onChange);
|
|
62
|
+
onChangeRef.current = onChange;
|
|
63
|
+
|
|
64
|
+
const mountEditor = useCallback(
|
|
65
|
+
(node: HTMLDivElement | null) => {
|
|
66
|
+
if (editorRef.current) {
|
|
67
|
+
editorRef.current.destroy();
|
|
68
|
+
editorRef.current = null;
|
|
69
|
+
}
|
|
70
|
+
if (!node) return;
|
|
71
|
+
containerRef.current = node;
|
|
72
|
+
|
|
73
|
+
const lang = language ?? (filePath ? detectLanguage(filePath) : "html");
|
|
74
|
+
|
|
75
|
+
const updateListener = EditorView.updateListener.of((update) => {
|
|
76
|
+
if (update.docChanged && onChangeRef.current) {
|
|
77
|
+
onChangeRef.current(update.state.doc.toString());
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const state = EditorState.create({
|
|
82
|
+
doc: content,
|
|
83
|
+
extensions: [
|
|
84
|
+
lineNumbers(),
|
|
85
|
+
highlightActiveLine(),
|
|
86
|
+
highlightActiveLineGutter(),
|
|
87
|
+
history(),
|
|
88
|
+
foldGutter(),
|
|
89
|
+
indentOnInput(),
|
|
90
|
+
bracketMatching(),
|
|
91
|
+
closeBrackets(),
|
|
92
|
+
highlightSelectionMatches(),
|
|
93
|
+
keymap.of([
|
|
94
|
+
...closeBracketsKeymap,
|
|
95
|
+
...defaultKeymap,
|
|
96
|
+
...searchKeymap,
|
|
97
|
+
...historyKeymap,
|
|
98
|
+
]),
|
|
99
|
+
getLanguageExtension(lang),
|
|
100
|
+
oneDark,
|
|
101
|
+
updateListener,
|
|
102
|
+
EditorState.readOnly.of(readOnly),
|
|
103
|
+
EditorView.theme({
|
|
104
|
+
"&": { height: "100%" },
|
|
105
|
+
".cm-scroller": { overflow: "auto" },
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
editorRef.current = new EditorView({ state, parent: node });
|
|
111
|
+
},
|
|
112
|
+
[content, filePath, language, readOnly],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
|
|
116
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
|
|
2
|
+
|
|
3
|
+
export interface CompositionLevel {
|
|
4
|
+
/** Unique id — "master" or composition file path */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Display label — "Master" or filename without extension */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Preview URL for this composition level */
|
|
9
|
+
previewUrl: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CompositionBreadcrumbProps {
|
|
13
|
+
stack: CompositionLevel[];
|
|
14
|
+
onNavigate: (index: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcrumbProps) {
|
|
18
|
+
if (stack.length <= 1) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<nav
|
|
22
|
+
aria-label="Composition navigation"
|
|
23
|
+
className="flex items-center gap-1 px-2 h-8 border-b border-neutral-800/50 bg-neutral-900/50 flex-shrink-0"
|
|
24
|
+
>
|
|
25
|
+
{/* Back button — always goes to parent */}
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={() => onNavigate(stack.length - 2)}
|
|
29
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
|
|
30
|
+
title="Back (Esc)"
|
|
31
|
+
>
|
|
32
|
+
<ArrowLeft size={12} weight="bold" />
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
{/* Breadcrumb path */}
|
|
36
|
+
{stack.map((level, i) => {
|
|
37
|
+
const isLast = i === stack.length - 1;
|
|
38
|
+
return (
|
|
39
|
+
<span key={level.id} className="flex items-center gap-1">
|
|
40
|
+
{i > 0 && <CaretRight size={10} className="text-neutral-600 flex-shrink-0" />}
|
|
41
|
+
{isLast ? (
|
|
42
|
+
<span className="text-xs text-neutral-200 font-medium">{level.label}</span>
|
|
43
|
+
) : (
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={() => onNavigate(i)}
|
|
47
|
+
className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
48
|
+
>
|
|
49
|
+
{level.label}
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</nav>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
|
|
2
|
+
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
3
|
+
import type { TimelineElement } from "../../player";
|
|
4
|
+
import { NLEPreview } from "./NLEPreview";
|
|
5
|
+
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
|
+
|
|
7
|
+
interface NLELayoutProps {
|
|
8
|
+
projectId: string;
|
|
9
|
+
portrait?: boolean;
|
|
10
|
+
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
|
11
|
+
previewOverlay?: ReactNode;
|
|
12
|
+
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
|
|
13
|
+
timelineFooter?: ReactNode;
|
|
14
|
+
/** Increment to force the preview to reload (e.g., after file writes) */
|
|
15
|
+
refreshKey?: number;
|
|
16
|
+
/** Navigate to a specific composition path (e.g., "compositions/intro.html") */
|
|
17
|
+
activeCompositionPath?: string | null;
|
|
18
|
+
/** Callback to expose the iframe ref (for element picker, etc.) */
|
|
19
|
+
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MIN_TIMELINE_H = 100;
|
|
23
|
+
const DEFAULT_TIMELINE_H = 220;
|
|
24
|
+
const MIN_PREVIEW_H = 120;
|
|
25
|
+
|
|
26
|
+
export const NLELayout = memo(function NLELayout({
|
|
27
|
+
projectId,
|
|
28
|
+
portrait,
|
|
29
|
+
previewOverlay,
|
|
30
|
+
timelineFooter,
|
|
31
|
+
refreshKey,
|
|
32
|
+
activeCompositionPath,
|
|
33
|
+
onIframeRef,
|
|
34
|
+
}: NLELayoutProps) {
|
|
35
|
+
const { iframeRef, togglePlay, seek, onIframeLoad: baseOnIframeLoad, saveSeekPosition } = useTimelinePlayer();
|
|
36
|
+
|
|
37
|
+
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
|
38
|
+
const prevRefreshKeyRef = useRef(refreshKey);
|
|
39
|
+
if (refreshKey !== prevRefreshKeyRef.current) {
|
|
40
|
+
prevRefreshKeyRef.current = refreshKey;
|
|
41
|
+
saveSeekPosition();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
45
|
+
const onIframeLoad = useCallback(() => {
|
|
46
|
+
baseOnIframeLoad();
|
|
47
|
+
onIframeRef?.(iframeRef.current);
|
|
48
|
+
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
|
49
|
+
|
|
50
|
+
// Composition ID → actual file path mapping, built from the raw index.html
|
|
51
|
+
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
fetch(`/api/projects/${projectId}/files/index.html`)
|
|
54
|
+
.then((r) => r.json())
|
|
55
|
+
.then((data: { content?: string }) => {
|
|
56
|
+
const html = data.content || "";
|
|
57
|
+
const map = new Map<string, string>();
|
|
58
|
+
const re = /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
|
|
59
|
+
let match;
|
|
60
|
+
while ((match = re.exec(html)) !== null) {
|
|
61
|
+
const id = match[1] || match[4];
|
|
62
|
+
const src = match[2] || match[3];
|
|
63
|
+
if (id && src) map.set(id, src);
|
|
64
|
+
}
|
|
65
|
+
setCompIdToSrc(map);
|
|
66
|
+
})
|
|
67
|
+
.catch(() => {});
|
|
68
|
+
}, [projectId]);
|
|
69
|
+
|
|
70
|
+
// Composition drill-down stack
|
|
71
|
+
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
|
72
|
+
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// Resizable timeline height
|
|
76
|
+
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
77
|
+
const isDragging = useRef(false);
|
|
78
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
|
|
80
|
+
// Current preview URL — derived from composition stack
|
|
81
|
+
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
82
|
+
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
83
|
+
|
|
84
|
+
// Drill-down: push a sub-composition onto the stack
|
|
85
|
+
const iframeRef_ = iframeRef; // stable ref for the callback
|
|
86
|
+
const handleDrillDown = useCallback(
|
|
87
|
+
(element: TimelineElement) => {
|
|
88
|
+
if (!element.compositionSrc) return;
|
|
89
|
+
// compositionSrc may be a full URL (from runtime manifest) or a relative path
|
|
90
|
+
// Extract the element's composition ID from its timeline ID
|
|
91
|
+
const compId = element.id;
|
|
92
|
+
|
|
93
|
+
// 1. Check compIdToSrc map (from index.html)
|
|
94
|
+
// 2. Scan the current iframe DOM for data-composition-src attribute
|
|
95
|
+
// 3. Fall back to stripping the compositionSrc to a relative path
|
|
96
|
+
let resolvedPath = compIdToSrc.get(compId);
|
|
97
|
+
if (!resolvedPath) {
|
|
98
|
+
try {
|
|
99
|
+
const doc = iframeRef_.current?.contentDocument;
|
|
100
|
+
if (doc) {
|
|
101
|
+
const host = doc.querySelector(`[data-composition-id="${compId}"][data-composition-src]`);
|
|
102
|
+
if (host) {
|
|
103
|
+
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch { /* cross-origin */ }
|
|
107
|
+
}
|
|
108
|
+
if (!resolvedPath) {
|
|
109
|
+
// Strip full URL to relative path if needed
|
|
110
|
+
const src = element.compositionSrc;
|
|
111
|
+
const compMatch = src.match(/compositions\/.*\.html/);
|
|
112
|
+
resolvedPath = compMatch ? compMatch[0] : src;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
usePlayerStore.getState().setElements([]);
|
|
116
|
+
|
|
117
|
+
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
|
118
|
+
setCompositionStack((prev) => {
|
|
119
|
+
const currentId = prev[prev.length - 1].id;
|
|
120
|
+
if (currentId === resolvedPath && prev.length > 1) {
|
|
121
|
+
return prev.slice(0, -1);
|
|
122
|
+
}
|
|
123
|
+
// Extract a clean label from the path (strip directories and extension)
|
|
124
|
+
const label = resolvedPath.split("/").pop()?.replace(/\.html$/, "") || resolvedPath;
|
|
125
|
+
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
|
|
126
|
+
return [...prev, { id: resolvedPath, label, previewUrl }];
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
[projectId, compIdToSrc],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Navigate back to a specific breadcrumb level
|
|
133
|
+
const handleNavigateComposition = useCallback(
|
|
134
|
+
(index: number) => {
|
|
135
|
+
usePlayerStore.getState().setElements([]);
|
|
136
|
+
setCompositionStack((prev) => prev.slice(0, index + 1));
|
|
137
|
+
},
|
|
138
|
+
[],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Navigate to a composition when activeCompositionPath changes
|
|
142
|
+
const prevActiveCompRef = useRef<string | null>(null);
|
|
143
|
+
if (activeCompositionPath && activeCompositionPath !== prevActiveCompRef.current) {
|
|
144
|
+
prevActiveCompRef.current = activeCompositionPath;
|
|
145
|
+
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
|
146
|
+
if (activeCompositionPath === "index.html") {
|
|
147
|
+
setCompositionStack((prev) => prev.length > 1 ? [prev[0]] : prev);
|
|
148
|
+
} else if (activeCompositionPath.startsWith("compositions/")) {
|
|
149
|
+
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
150
|
+
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
|
151
|
+
setCompositionStack((prev) => {
|
|
152
|
+
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
|
|
153
|
+
return [
|
|
154
|
+
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
155
|
+
{ id: activeCompositionPath, label, previewUrl },
|
|
156
|
+
];
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
} else if (!activeCompositionPath && prevActiveCompRef.current) {
|
|
160
|
+
prevActiveCompRef.current = null;
|
|
161
|
+
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resize divider handlers
|
|
165
|
+
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
isDragging.current = true;
|
|
168
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
|
|
172
|
+
if (!isDragging.current || !containerRef.current) return;
|
|
173
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
174
|
+
const mouseY = e.clientY - rect.top;
|
|
175
|
+
const containerH = rect.height;
|
|
176
|
+
const newTimelineH = Math.max(
|
|
177
|
+
MIN_TIMELINE_H,
|
|
178
|
+
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
179
|
+
);
|
|
180
|
+
setTimelineH(newTimelineH);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const handleDividerPointerUp = useCallback(() => {
|
|
184
|
+
isDragging.current = false;
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
// Keyboard: Escape to pop composition level
|
|
188
|
+
const handleKeyDown = useCallback(
|
|
189
|
+
(e: React.KeyboardEvent) => {
|
|
190
|
+
if (e.key === "Escape" && compositionStack.length > 1) {
|
|
191
|
+
setCompositionStack((prev) => prev.slice(0, -1));
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
[compositionStack.length],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div
|
|
199
|
+
ref={containerRef}
|
|
200
|
+
className="flex flex-col h-full min-h-0 bg-neutral-950"
|
|
201
|
+
onKeyDown={handleKeyDown}
|
|
202
|
+
tabIndex={-1}
|
|
203
|
+
>
|
|
204
|
+
{/* Preview — takes remaining space above timeline */}
|
|
205
|
+
<div className="flex-1 min-h-0 relative">
|
|
206
|
+
<NLEPreview
|
|
207
|
+
projectId={projectId}
|
|
208
|
+
iframeRef={iframeRef}
|
|
209
|
+
onIframeLoad={onIframeLoad}
|
|
210
|
+
portrait={portrait}
|
|
211
|
+
directUrl={directUrl}
|
|
212
|
+
refreshKey={refreshKey}
|
|
213
|
+
/>
|
|
214
|
+
{previewOverlay}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Resize divider */}
|
|
218
|
+
<div
|
|
219
|
+
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-row-resize transition-colors active:bg-blue-400 z-10"
|
|
220
|
+
style={{ touchAction: "none" }}
|
|
221
|
+
onPointerDown={handleDividerPointerDown}
|
|
222
|
+
onPointerMove={handleDividerPointerMove}
|
|
223
|
+
onPointerUp={handleDividerPointerUp}
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
{/* Timeline section — fixed height, resizable */}
|
|
227
|
+
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
|
228
|
+
{/* Breadcrumb + Player controls */}
|
|
229
|
+
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
|
230
|
+
{compositionStack.length > 1 && (
|
|
231
|
+
<CompositionBreadcrumb stack={compositionStack} onNavigate={handleNavigateComposition} />
|
|
232
|
+
)}
|
|
233
|
+
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Timeline tracks */}
|
|
237
|
+
<div
|
|
238
|
+
className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
|
|
239
|
+
onDoubleClick={(e) => {
|
|
240
|
+
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
241
|
+
if (compositionStack.length > 1) {
|
|
242
|
+
setCompositionStack((prev) => prev.slice(0, -1));
|
|
243
|
+
}
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
|
|
247
|
+
{timelineFooter}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { memo, type Ref } from "react";
|
|
2
|
+
import { Player } from "../../player";
|
|
3
|
+
|
|
4
|
+
interface NLEPreviewProps {
|
|
5
|
+
projectId: string;
|
|
6
|
+
iframeRef: Ref<HTMLIFrameElement>;
|
|
7
|
+
onIframeLoad: () => void;
|
|
8
|
+
portrait?: boolean;
|
|
9
|
+
directUrl?: string;
|
|
10
|
+
refreshKey?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const NLEPreview = memo(function NLEPreview({
|
|
14
|
+
projectId,
|
|
15
|
+
iframeRef,
|
|
16
|
+
onIframeLoad,
|
|
17
|
+
portrait,
|
|
18
|
+
directUrl,
|
|
19
|
+
refreshKey,
|
|
20
|
+
}: NLEPreviewProps) {
|
|
21
|
+
const playerKey = `${directUrl ?? projectId}_${refreshKey ?? 0}`;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex flex-col h-full min-h-0">
|
|
25
|
+
<div className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
|
|
26
|
+
<Player
|
|
27
|
+
key={playerKey}
|
|
28
|
+
ref={iframeRef}
|
|
29
|
+
projectId={directUrl ? undefined : projectId}
|
|
30
|
+
directUrl={directUrl}
|
|
31
|
+
onLoad={onIframeLoad}
|
|
32
|
+
portrait={portrait}
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
});
|