@rendiv/studio 0.1.2 → 0.1.4
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/apply-overrides.d.ts +32 -0
- package/dist/apply-overrides.d.ts.map +1 -0
- package/dist/apply-overrides.js +388 -0
- package/dist/apply-overrides.js.map +1 -0
- package/dist/vite-plugin-studio.d.ts.map +1 -1
- package/dist/vite-plugin-studio.js +179 -0
- package/dist/vite-plugin-studio.js.map +1 -1
- package/package.json +6 -3
- package/ui/Preview.tsx +1 -0
- package/ui/RenderQueue.tsx +15 -55
- package/ui/StudioApp.tsx +368 -32
- package/ui/Terminal.tsx +266 -0
- package/ui/TimelineEditor.tsx +573 -0
- package/ui/TopBar.tsx +9 -9
- package/ui/timeline/track-layout.ts +94 -0
- package/ui/timeline/types.ts +39 -0
- package/ui/timeline/use-timeline-drag.ts +98 -0
- package/ui/timeline/use-timeline-zoom.ts +65 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import React, { useRef, useCallback, useMemo, useEffect, useState } from 'react';
|
|
2
|
+
import type { TimelineEditorProps, TrackEntry } from './timeline/types';
|
|
3
|
+
import { assignTracks } from './timeline/track-layout';
|
|
4
|
+
import { useTimelineZoom } from './timeline/use-timeline-zoom';
|
|
5
|
+
import { useTimelineDrag } from './timeline/use-timeline-drag';
|
|
6
|
+
|
|
7
|
+
const TRACK_HEIGHT = 32;
|
|
8
|
+
const TRACK_GAP = 2;
|
|
9
|
+
const RULER_HEIGHT = 28;
|
|
10
|
+
const LABEL_WIDTH = 140;
|
|
11
|
+
const TOOLBAR_HEIGHT = 32;
|
|
12
|
+
const EDGE_HIT_ZONE = 6;
|
|
13
|
+
|
|
14
|
+
// Block colors — cycle through a palette
|
|
15
|
+
const BLOCK_COLORS = [
|
|
16
|
+
'#1f6feb', '#238636', '#8957e5', '#da3633',
|
|
17
|
+
'#d29922', '#1a7f37', '#6639ba', '#cf222e',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function getBlockColor(index: number): string {
|
|
21
|
+
return BLOCK_COLORS[index % BLOCK_COLORS.length];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatTimecode(frame: number, fps: number): string {
|
|
25
|
+
const totalSeconds = frame / fps;
|
|
26
|
+
const m = Math.floor(totalSeconds / 60);
|
|
27
|
+
const s = Math.floor(totalSeconds % 60);
|
|
28
|
+
const f = frame % fps;
|
|
29
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const TimelineEditor: React.FC<TimelineEditorProps> = ({
|
|
33
|
+
entries,
|
|
34
|
+
currentFrame,
|
|
35
|
+
totalFrames,
|
|
36
|
+
fps,
|
|
37
|
+
compositionName,
|
|
38
|
+
onSeek,
|
|
39
|
+
overrides,
|
|
40
|
+
onOverrideChange,
|
|
41
|
+
onOverrideRemove,
|
|
42
|
+
onOverridesClear,
|
|
43
|
+
view,
|
|
44
|
+
onViewChange,
|
|
45
|
+
}) => {
|
|
46
|
+
const trackAreaRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
48
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; namePath: string } | null>(null);
|
|
49
|
+
|
|
50
|
+
const { pixelsPerFrame, scrollLeft, setScrollLeft, setPixelsPerFrame, handleWheel } = useTimelineZoom({
|
|
51
|
+
totalFrames,
|
|
52
|
+
containerRef: trackAreaRef,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const tracks = useMemo(
|
|
56
|
+
() => assignTracks(entries, overrides),
|
|
57
|
+
[entries, overrides],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const tracksRef = useRef(tracks);
|
|
61
|
+
tracksRef.current = tracks;
|
|
62
|
+
|
|
63
|
+
// Check whether a block can be placed on a track without overlapping other blocks
|
|
64
|
+
const canPlaceOnTrack = useCallback((namePath: string, from: number, duration: number, targetTrack: number): boolean => {
|
|
65
|
+
const currentTracks = tracksRef.current;
|
|
66
|
+
if (targetTrack >= currentTracks.length) return true; // new empty track
|
|
67
|
+
const trackEntries = currentTracks[targetTrack]?.entries ?? [];
|
|
68
|
+
const end = from + duration;
|
|
69
|
+
return !trackEntries.some((te) => {
|
|
70
|
+
if (te.entry.namePath === namePath) return false; // skip self
|
|
71
|
+
const teEnd = te.entry.from + te.entry.durationInFrames;
|
|
72
|
+
return from < teEnd && end > te.entry.from; // overlap check
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const { startDrag } = useTimelineDrag({
|
|
77
|
+
pixelsPerFrame,
|
|
78
|
+
trackHeight: TRACK_HEIGHT,
|
|
79
|
+
trackGap: TRACK_GAP,
|
|
80
|
+
onOverrideChange,
|
|
81
|
+
canPlaceOnTrack,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Attach wheel handler
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const el = trackAreaRef.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
el.addEventListener('wheel', handleWheel, { passive: false });
|
|
89
|
+
return () => el.removeEventListener('wheel', handleWheel);
|
|
90
|
+
}, [handleWheel]);
|
|
91
|
+
|
|
92
|
+
// Sync scroll position
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const el = trackAreaRef.current;
|
|
95
|
+
if (el) el.scrollLeft = scrollLeft;
|
|
96
|
+
}, [scrollLeft]);
|
|
97
|
+
|
|
98
|
+
// Close context menu on click elsewhere
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!contextMenu) return;
|
|
101
|
+
const close = () => setContextMenu(null);
|
|
102
|
+
window.addEventListener('click', close);
|
|
103
|
+
return () => window.removeEventListener('click', close);
|
|
104
|
+
}, [contextMenu]);
|
|
105
|
+
|
|
106
|
+
const totalWidth = totalFrames * pixelsPerFrame;
|
|
107
|
+
const trackAreaHeight = Math.max(1, tracks.length) * (TRACK_HEIGHT + TRACK_GAP);
|
|
108
|
+
|
|
109
|
+
// Ruler tick computation
|
|
110
|
+
const rulerTicks = useMemo(() => {
|
|
111
|
+
const ticks: { frame: number; label: string; major: boolean }[] = [];
|
|
112
|
+
// Target ~100px between major ticks
|
|
113
|
+
const framesPerMajor = Math.max(1, Math.round(100 / pixelsPerFrame));
|
|
114
|
+
// Round to nice intervals
|
|
115
|
+
const nice = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600];
|
|
116
|
+
let interval = nice.find((n) => n >= framesPerMajor) ?? framesPerMajor;
|
|
117
|
+
// Make sure minor ticks divide evenly
|
|
118
|
+
const minorInterval = interval >= 10 ? interval / 5 : interval >= 2 ? interval / 2 : 1;
|
|
119
|
+
|
|
120
|
+
for (let f = 0; f <= totalFrames; f += minorInterval) {
|
|
121
|
+
const fr = Math.round(f);
|
|
122
|
+
if (fr > totalFrames) break;
|
|
123
|
+
const isMajor = fr % interval === 0;
|
|
124
|
+
ticks.push({ frame: fr, label: isMajor ? formatTimecode(fr, fps) : '', major: isMajor });
|
|
125
|
+
}
|
|
126
|
+
return ticks;
|
|
127
|
+
}, [totalFrames, pixelsPerFrame, fps]);
|
|
128
|
+
|
|
129
|
+
// Playhead seeking via ruler click/drag
|
|
130
|
+
const isSeekingRef = useRef(false);
|
|
131
|
+
|
|
132
|
+
const getFrameFromClientX = useCallback((clientX: number): number => {
|
|
133
|
+
const el = trackAreaRef.current;
|
|
134
|
+
if (!el) return 0;
|
|
135
|
+
const rect = el.getBoundingClientRect();
|
|
136
|
+
const x = clientX - rect.left + el.scrollLeft;
|
|
137
|
+
return Math.max(0, Math.min(totalFrames - 1, Math.round(x / pixelsPerFrame)));
|
|
138
|
+
}, [pixelsPerFrame, totalFrames]);
|
|
139
|
+
|
|
140
|
+
const handleRulerMouseDown = useCallback((e: React.MouseEvent) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
isSeekingRef.current = true;
|
|
143
|
+
onSeek(getFrameFromClientX(e.clientX));
|
|
144
|
+
}, [getFrameFromClientX, onSeek]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
148
|
+
if (!isSeekingRef.current) return;
|
|
149
|
+
onSeek(getFrameFromClientX(e.clientX));
|
|
150
|
+
};
|
|
151
|
+
const handleMouseUp = () => { isSeekingRef.current = false; };
|
|
152
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
153
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
154
|
+
return () => {
|
|
155
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
156
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
157
|
+
};
|
|
158
|
+
}, [getFrameFromClientX, onSeek]);
|
|
159
|
+
|
|
160
|
+
// Block mouse handlers
|
|
161
|
+
const handleBlockMouseDown = useCallback((e: React.MouseEvent, te: TrackEntry) => {
|
|
162
|
+
e.stopPropagation();
|
|
163
|
+
if (e.button !== 0) return;
|
|
164
|
+
|
|
165
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
166
|
+
const relX = e.clientX - rect.left;
|
|
167
|
+
const width = rect.width;
|
|
168
|
+
|
|
169
|
+
let edge: 'body' | 'left' | 'right' = 'body';
|
|
170
|
+
if (relX <= EDGE_HIT_ZONE) edge = 'left';
|
|
171
|
+
else if (relX >= width - EDGE_HIT_ZONE) edge = 'right';
|
|
172
|
+
|
|
173
|
+
setSelectedPath(te.entry.namePath);
|
|
174
|
+
startDrag(te.entry.namePath, edge, e.clientX, e.clientY, te.entry.from, te.entry.durationInFrames, te.trackIndex);
|
|
175
|
+
}, [startDrag]);
|
|
176
|
+
|
|
177
|
+
const handleBlockContextMenu = useCallback((e: React.MouseEvent, te: TrackEntry) => {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
setContextMenu({ x: e.clientX, y: e.clientY, namePath: te.entry.namePath });
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
// Track area click for seeking (when clicking empty space)
|
|
184
|
+
const handleTrackAreaClick = useCallback((e: React.MouseEvent) => {
|
|
185
|
+
if (e.target === e.currentTarget || (e.target as HTMLElement).dataset?.trackBg) {
|
|
186
|
+
onSeek(getFrameFromClientX(e.clientX));
|
|
187
|
+
setSelectedPath(null);
|
|
188
|
+
}
|
|
189
|
+
}, [getFrameFromClientX, onSeek]);
|
|
190
|
+
|
|
191
|
+
// Zoom slider
|
|
192
|
+
const handleZoomChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
193
|
+
setPixelsPerFrame(Number(e.target.value));
|
|
194
|
+
}, [setPixelsPerFrame]);
|
|
195
|
+
|
|
196
|
+
const playheadX = currentFrame * pixelsPerFrame;
|
|
197
|
+
const hasOverrides = overrides.size > 0;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div style={containerStyle}>
|
|
201
|
+
{/* Toolbar */}
|
|
202
|
+
<div style={toolbarStyle}>
|
|
203
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
204
|
+
<span style={{ fontSize: 11, color: '#8b949e' }}>{compositionName}</span>
|
|
205
|
+
<span style={{ fontSize: 11, color: '#484f58' }}>|</span>
|
|
206
|
+
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'monospace' }}>
|
|
207
|
+
{formatTimecode(currentFrame, fps)} / {formatTimecode(totalFrames, fps)}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
211
|
+
{hasOverrides && (
|
|
212
|
+
<button onClick={onOverridesClear} style={resetBtnStyle} title="Reset all overrides">
|
|
213
|
+
Reset All
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#8b949e' }}>
|
|
217
|
+
Zoom
|
|
218
|
+
<input
|
|
219
|
+
type="range"
|
|
220
|
+
min={0.5}
|
|
221
|
+
max={20}
|
|
222
|
+
step={0.1}
|
|
223
|
+
value={pixelsPerFrame}
|
|
224
|
+
onChange={handleZoomChange}
|
|
225
|
+
style={{ width: 80, accentColor: '#58a6ff' }}
|
|
226
|
+
/>
|
|
227
|
+
</label>
|
|
228
|
+
<ViewToggle view={view} onChange={onViewChange} />
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Main area: labels + tracks + ruler */}
|
|
233
|
+
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
234
|
+
{/* Track labels */}
|
|
235
|
+
<div style={labelsContainerStyle}>
|
|
236
|
+
<div style={{ height: RULER_HEIGHT, borderBottom: '1px solid #30363d' }} />
|
|
237
|
+
{tracks.map((track) => (
|
|
238
|
+
<div key={track.id} style={trackLabelStyle}>
|
|
239
|
+
<span style={{ fontSize: 10, color: '#484f58' }}>Track {track.id + 1}</span>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
{tracks.length === 0 && (
|
|
243
|
+
<div style={{ ...trackLabelStyle, color: '#484f58', fontStyle: 'italic' }}>
|
|
244
|
+
No sequences
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Scrollable track area */}
|
|
250
|
+
<div
|
|
251
|
+
ref={trackAreaRef}
|
|
252
|
+
style={trackAreaContainerStyle}
|
|
253
|
+
onClick={handleTrackAreaClick}
|
|
254
|
+
onScroll={(e) => setScrollLeft((e.target as HTMLElement).scrollLeft)}
|
|
255
|
+
>
|
|
256
|
+
{/* Ruler */}
|
|
257
|
+
<div style={{ ...rulerStyle, width: totalWidth }}>
|
|
258
|
+
{rulerTicks.map((tick, i) => (
|
|
259
|
+
<div
|
|
260
|
+
key={i}
|
|
261
|
+
style={{
|
|
262
|
+
position: 'absolute',
|
|
263
|
+
left: tick.frame * pixelsPerFrame,
|
|
264
|
+
top: 0,
|
|
265
|
+
height: '100%',
|
|
266
|
+
display: 'flex',
|
|
267
|
+
flexDirection: 'column',
|
|
268
|
+
justifyContent: 'flex-end',
|
|
269
|
+
alignItems: 'center',
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<div style={{
|
|
273
|
+
width: 1,
|
|
274
|
+
height: tick.major ? 10 : 5,
|
|
275
|
+
backgroundColor: tick.major ? '#484f58' : '#30363d',
|
|
276
|
+
}} />
|
|
277
|
+
{tick.label && (
|
|
278
|
+
<span style={{
|
|
279
|
+
position: 'absolute',
|
|
280
|
+
top: 2,
|
|
281
|
+
left: 2,
|
|
282
|
+
fontSize: 9,
|
|
283
|
+
color: '#8b949e',
|
|
284
|
+
fontFamily: 'monospace',
|
|
285
|
+
whiteSpace: 'nowrap',
|
|
286
|
+
pointerEvents: 'none',
|
|
287
|
+
}}>
|
|
288
|
+
{tick.label}
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
))}
|
|
293
|
+
{/* Ruler seek overlay */}
|
|
294
|
+
<div
|
|
295
|
+
style={{ position: 'absolute', inset: 0, cursor: 'pointer' }}
|
|
296
|
+
onMouseDown={handleRulerMouseDown}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Track rows */}
|
|
301
|
+
<div style={{ position: 'relative', width: totalWidth, minHeight: trackAreaHeight }}>
|
|
302
|
+
{/* Track background rows */}
|
|
303
|
+
{tracks.map((track) => (
|
|
304
|
+
<div
|
|
305
|
+
key={track.id}
|
|
306
|
+
data-track-bg="true"
|
|
307
|
+
style={{
|
|
308
|
+
position: 'absolute',
|
|
309
|
+
top: track.id * (TRACK_HEIGHT + TRACK_GAP),
|
|
310
|
+
left: 0,
|
|
311
|
+
width: totalWidth,
|
|
312
|
+
height: TRACK_HEIGHT,
|
|
313
|
+
backgroundColor: track.id % 2 === 0 ? '#161b22' : '#1c2128',
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
))}
|
|
317
|
+
|
|
318
|
+
{/* Blocks */}
|
|
319
|
+
{tracks.flatMap((track) =>
|
|
320
|
+
track.entries.map((te) => {
|
|
321
|
+
const left = te.entry.from * pixelsPerFrame;
|
|
322
|
+
const width = Math.max(2, te.entry.durationInFrames * pixelsPerFrame);
|
|
323
|
+
const top = te.trackIndex * (TRACK_HEIGHT + TRACK_GAP);
|
|
324
|
+
const isSelected = te.entry.namePath === selectedPath;
|
|
325
|
+
const color = getBlockColor(te.trackIndex);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div
|
|
329
|
+
key={te.entry.id}
|
|
330
|
+
onMouseDown={(e) => handleBlockMouseDown(e, te)}
|
|
331
|
+
onContextMenu={(e) => handleBlockContextMenu(e, te)}
|
|
332
|
+
title={`${te.entry.namePath}\nFrom: ${te.entry.from} Duration: ${te.entry.durationInFrames}`}
|
|
333
|
+
style={{
|
|
334
|
+
position: 'absolute',
|
|
335
|
+
left,
|
|
336
|
+
top: top + 2,
|
|
337
|
+
width,
|
|
338
|
+
height: TRACK_HEIGHT - 4,
|
|
339
|
+
backgroundColor: color,
|
|
340
|
+
opacity: isSelected ? 1 : 0.75,
|
|
341
|
+
borderRadius: 4,
|
|
342
|
+
border: isSelected ? '1px solid #58a6ff' : '1px solid transparent',
|
|
343
|
+
cursor: 'grab',
|
|
344
|
+
display: 'flex',
|
|
345
|
+
alignItems: 'center',
|
|
346
|
+
overflow: 'hidden',
|
|
347
|
+
userSelect: 'none',
|
|
348
|
+
boxSizing: 'border-box',
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
{/* Left resize handle */}
|
|
352
|
+
<div style={{ ...edgeHandleStyle, left: 0, cursor: 'ew-resize' }} />
|
|
353
|
+
|
|
354
|
+
{/* Label */}
|
|
355
|
+
<span style={{
|
|
356
|
+
flex: 1,
|
|
357
|
+
fontSize: 10,
|
|
358
|
+
color: '#fff',
|
|
359
|
+
padding: '0 8px',
|
|
360
|
+
overflow: 'hidden',
|
|
361
|
+
textOverflow: 'ellipsis',
|
|
362
|
+
whiteSpace: 'nowrap',
|
|
363
|
+
pointerEvents: 'none',
|
|
364
|
+
fontWeight: 500,
|
|
365
|
+
}}>
|
|
366
|
+
{te.entry.name}
|
|
367
|
+
{te.hasOverride && (
|
|
368
|
+
<span style={{ marginLeft: 4, fontSize: 8, opacity: 0.7 }}>*</span>
|
|
369
|
+
)}
|
|
370
|
+
</span>
|
|
371
|
+
|
|
372
|
+
{/* Right resize handle */}
|
|
373
|
+
<div style={{ ...edgeHandleStyle, right: 0, cursor: 'ew-resize' }} />
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}),
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
{/* Playhead */}
|
|
380
|
+
<div style={{
|
|
381
|
+
position: 'absolute',
|
|
382
|
+
left: playheadX,
|
|
383
|
+
top: -RULER_HEIGHT,
|
|
384
|
+
width: 1,
|
|
385
|
+
height: trackAreaHeight + RULER_HEIGHT,
|
|
386
|
+
backgroundColor: '#f85149',
|
|
387
|
+
pointerEvents: 'none',
|
|
388
|
+
zIndex: 10,
|
|
389
|
+
}}>
|
|
390
|
+
{/* Playhead marker */}
|
|
391
|
+
<div style={{
|
|
392
|
+
position: 'absolute',
|
|
393
|
+
top: 0,
|
|
394
|
+
left: -5,
|
|
395
|
+
width: 0,
|
|
396
|
+
height: 0,
|
|
397
|
+
borderLeft: '5px solid transparent',
|
|
398
|
+
borderRight: '5px solid transparent',
|
|
399
|
+
borderTop: '8px solid #f85149',
|
|
400
|
+
}} />
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{/* Selection info bar */}
|
|
407
|
+
{selectedPath && (
|
|
408
|
+
<div style={infoBarStyle}>
|
|
409
|
+
<span style={{ fontSize: 11, color: '#e6edf3', fontFamily: 'monospace' }}>{selectedPath}</span>
|
|
410
|
+
{overrides.has(selectedPath) && (
|
|
411
|
+
<button
|
|
412
|
+
onClick={() => { onOverrideRemove(selectedPath); setSelectedPath(null); }}
|
|
413
|
+
style={resetBtnStyle}
|
|
414
|
+
>
|
|
415
|
+
Reset Position
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
)}
|
|
420
|
+
|
|
421
|
+
{/* Context menu */}
|
|
422
|
+
{contextMenu && (
|
|
423
|
+
<div style={{ ...contextMenuStyle, left: contextMenu.x, top: contextMenu.y }}>
|
|
424
|
+
{overrides.has(contextMenu.namePath) && (
|
|
425
|
+
<div
|
|
426
|
+
style={contextMenuItemStyle}
|
|
427
|
+
onClick={() => { onOverrideRemove(contextMenu.namePath); setContextMenu(null); }}
|
|
428
|
+
>
|
|
429
|
+
Reset Position
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
<div
|
|
433
|
+
style={contextMenuItemStyle}
|
|
434
|
+
onClick={() => { setSelectedPath(contextMenu.namePath); setContextMenu(null); }}
|
|
435
|
+
>
|
|
436
|
+
Select
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// --- View Toggle (same style as StudioApp) ---
|
|
445
|
+
const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
|
|
446
|
+
<div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
|
|
447
|
+
{(['editor', 'tree'] as const).map((v) => (
|
|
448
|
+
<button
|
|
449
|
+
key={v}
|
|
450
|
+
onClick={() => onChange(v)}
|
|
451
|
+
style={{
|
|
452
|
+
padding: '3px 10px',
|
|
453
|
+
fontSize: 11,
|
|
454
|
+
fontWeight: 500,
|
|
455
|
+
border: 'none',
|
|
456
|
+
borderRadius: 4,
|
|
457
|
+
cursor: 'pointer',
|
|
458
|
+
backgroundColor: view === v ? '#30363d' : 'transparent',
|
|
459
|
+
color: view === v ? '#e6edf3' : '#8b949e',
|
|
460
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
461
|
+
}}
|
|
462
|
+
>
|
|
463
|
+
{v === 'editor' ? 'Tracks' : 'Tree'}
|
|
464
|
+
</button>
|
|
465
|
+
))}
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// --- Styles ---
|
|
470
|
+
|
|
471
|
+
const containerStyle: React.CSSProperties = {
|
|
472
|
+
display: 'flex',
|
|
473
|
+
flexDirection: 'column',
|
|
474
|
+
height: '100%',
|
|
475
|
+
backgroundColor: '#0d1117',
|
|
476
|
+
color: '#e6edf3',
|
|
477
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
478
|
+
fontSize: 13,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const toolbarStyle: React.CSSProperties = {
|
|
482
|
+
display: 'flex',
|
|
483
|
+
alignItems: 'center',
|
|
484
|
+
justifyContent: 'space-between',
|
|
485
|
+
height: TOOLBAR_HEIGHT,
|
|
486
|
+
padding: '0 12px',
|
|
487
|
+
backgroundColor: '#161b22',
|
|
488
|
+
borderBottom: '1px solid #30363d',
|
|
489
|
+
flexShrink: 0,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const labelsContainerStyle: React.CSSProperties = {
|
|
493
|
+
width: LABEL_WIDTH,
|
|
494
|
+
minWidth: LABEL_WIDTH,
|
|
495
|
+
flexShrink: 0,
|
|
496
|
+
backgroundColor: '#161b22',
|
|
497
|
+
borderRight: '1px solid #30363d',
|
|
498
|
+
overflow: 'hidden',
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const trackLabelStyle: React.CSSProperties = {
|
|
502
|
+
height: TRACK_HEIGHT + TRACK_GAP,
|
|
503
|
+
display: 'flex',
|
|
504
|
+
alignItems: 'center',
|
|
505
|
+
padding: '0 8px',
|
|
506
|
+
fontSize: 11,
|
|
507
|
+
color: '#8b949e',
|
|
508
|
+
borderBottom: '1px solid #21262d',
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const trackAreaContainerStyle: React.CSSProperties = {
|
|
512
|
+
flex: 1,
|
|
513
|
+
overflow: 'auto',
|
|
514
|
+
position: 'relative',
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const rulerStyle: React.CSSProperties = {
|
|
518
|
+
position: 'sticky',
|
|
519
|
+
top: 0,
|
|
520
|
+
height: RULER_HEIGHT,
|
|
521
|
+
backgroundColor: '#161b22',
|
|
522
|
+
borderBottom: '1px solid #30363d',
|
|
523
|
+
zIndex: 5,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const edgeHandleStyle: React.CSSProperties = {
|
|
527
|
+
position: 'absolute',
|
|
528
|
+
top: 0,
|
|
529
|
+
width: EDGE_HIT_ZONE,
|
|
530
|
+
height: '100%',
|
|
531
|
+
zIndex: 1,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const resetBtnStyle: React.CSSProperties = {
|
|
535
|
+
padding: '2px 8px',
|
|
536
|
+
fontSize: 10,
|
|
537
|
+
fontWeight: 500,
|
|
538
|
+
color: '#f85149',
|
|
539
|
+
backgroundColor: 'transparent',
|
|
540
|
+
border: '1px solid #f8514933',
|
|
541
|
+
borderRadius: 4,
|
|
542
|
+
cursor: 'pointer',
|
|
543
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const infoBarStyle: React.CSSProperties = {
|
|
547
|
+
display: 'flex',
|
|
548
|
+
alignItems: 'center',
|
|
549
|
+
justifyContent: 'space-between',
|
|
550
|
+
height: 24,
|
|
551
|
+
padding: '0 12px',
|
|
552
|
+
backgroundColor: '#161b22',
|
|
553
|
+
borderTop: '1px solid #30363d',
|
|
554
|
+
flexShrink: 0,
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const contextMenuStyle: React.CSSProperties = {
|
|
558
|
+
position: 'fixed',
|
|
559
|
+
zIndex: 100,
|
|
560
|
+
backgroundColor: '#1c2128',
|
|
561
|
+
border: '1px solid #30363d',
|
|
562
|
+
borderRadius: 6,
|
|
563
|
+
padding: '4px 0',
|
|
564
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
|
565
|
+
minWidth: 140,
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const contextMenuItemStyle: React.CSSProperties = {
|
|
569
|
+
padding: '6px 12px',
|
|
570
|
+
fontSize: 12,
|
|
571
|
+
color: '#e6edf3',
|
|
572
|
+
cursor: 'pointer',
|
|
573
|
+
};
|
package/ui/TopBar.tsx
CHANGED
|
@@ -9,8 +9,8 @@ interface TopBarProps {
|
|
|
9
9
|
entryPoint: string;
|
|
10
10
|
onRender: () => void;
|
|
11
11
|
queueCount: number;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
panelOpen: boolean;
|
|
13
|
+
onTogglePanel: () => void;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const RenderIcon: React.FC = () => (
|
|
@@ -19,14 +19,14 @@ const RenderIcon: React.FC = () => (
|
|
|
19
19
|
</svg>
|
|
20
20
|
);
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const PanelIcon: React.FC<{ open: boolean }> = ({ open }) => (
|
|
23
23
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
24
24
|
<rect x="10" y="2" width="4" height="12" rx="1" fill={open ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.5" />
|
|
25
25
|
<rect x="2" y="2" width="6" height="12" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
|
26
26
|
</svg>
|
|
27
27
|
);
|
|
28
28
|
|
|
29
|
-
export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRender, queueCount,
|
|
29
|
+
export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRender, queueCount, panelOpen, onTogglePanel }) => {
|
|
30
30
|
const [copied, setCopied] = useState(false);
|
|
31
31
|
|
|
32
32
|
const handleCopyCommand = useCallback(() => {
|
|
@@ -94,13 +94,13 @@ export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRende
|
|
|
94
94
|
alignItems: 'center',
|
|
95
95
|
gap: 6,
|
|
96
96
|
position: 'relative' as const,
|
|
97
|
-
color:
|
|
98
|
-
borderColor:
|
|
97
|
+
color: panelOpen ? colors.accent : colors.textPrimary,
|
|
98
|
+
borderColor: panelOpen ? colors.accent : colors.border,
|
|
99
99
|
}}
|
|
100
|
-
onClick={
|
|
101
|
-
title="Toggle
|
|
100
|
+
onClick={onTogglePanel}
|
|
101
|
+
title="Toggle side panel"
|
|
102
102
|
>
|
|
103
|
-
<
|
|
103
|
+
<PanelIcon open={panelOpen} />
|
|
104
104
|
{queueCount > 0 && (
|
|
105
105
|
<span style={badgeStyle}>{queueCount}</span>
|
|
106
106
|
)}
|