babylon.quarks-editor 0.17.7 → 0.17.8
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/core/timeline.d.ts +1 -1
- package/dist/core/timeline.js +3 -2
- package/dist/react/EffectEditor.d.ts +3 -0
- package/dist/react/EffectEditor.js +6 -1
- package/dist/react/EffectEditorHost.js +72 -11
- package/dist/react/ObjectModule.d.ts +6 -0
- package/dist/react/ObjectModule.js +28 -0
- package/dist/react/TimelinePanel.d.ts +4 -3
- package/dist/react/TimelinePanel.js +60 -10
- package/dist/react/TimelineTrackRow.d.ts +2 -2
- package/dist/react/TimelineTrackRow.js +16 -12
- package/dist/react/theme.js +44 -21
- package/package.json +68 -63
- package/src/core/binding.ts +105 -105
- package/src/core/collision.ts +40 -40
- package/src/core/groups.ts +29 -29
- package/src/core/loadEffect.ts +102 -102
- package/src/core/scrub.ts +28 -28
- package/src/core/shapes.ts +133 -133
- package/src/core/systems.ts +98 -98
- package/src/core/timeline.ts +95 -94
- package/src/core/values.ts +113 -113
- package/src/index.ts +32 -32
- package/src/react/CurveEditor.tsx +168 -168
- package/src/react/EffectEditor.tsx +125 -111
- package/src/react/EffectEditorHost.tsx +466 -377
- package/src/react/GradientEditor.tsx +127 -127
- package/src/react/ModuleSection.tsx +54 -54
- package/src/react/ObjectModule.tsx +47 -0
- package/src/react/PromptDialog.tsx +85 -85
- package/src/react/TimelinePanel.tsx +412 -354
- package/src/react/TimelineTrackRow.tsx +291 -267
- package/src/react/behaviorModules.tsx +658 -658
- package/src/react/fields.tsx +76 -76
- package/src/react/index.ts +43 -43
- package/src/react/modules.tsx +1034 -1034
- package/src/react/theme.ts +108 -85
|
@@ -1,354 +1,412 @@
|
|
|
1
|
-
import React, {useMemo, useRef, useState} from 'react';
|
|
2
|
-
import type {ParticleSystem, BatchedRenderer} from 'babylon.quarks';
|
|
3
|
-
import type {Scene} from '@babylonjs/core/scene';
|
|
4
|
-
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
5
|
-
import type {EffectBinding} from '../core/binding';
|
|
6
|
-
import {groupNodes} from '../core/groups';
|
|
7
|
-
import {scrubTo} from '../core/scrub';
|
|
8
|
-
import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
|
|
9
|
-
import {TimelineTrackRow} from './TimelineTrackRow';
|
|
10
|
-
import {buttonStyle, theme} from './theme';
|
|
11
|
-
|
|
12
|
-
export interface PlaybackState {
|
|
13
|
-
paused: boolean;
|
|
14
|
-
speed: number;
|
|
15
|
-
stepQueued: boolean;
|
|
16
|
-
elapsed: number;
|
|
17
|
-
scrubbing: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface TimelinePanelProps {
|
|
21
|
-
binding: EffectBinding;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
scene: Scene;
|
|
25
|
-
renderer: BatchedRenderer;
|
|
26
|
-
playbackRef: React.MutableRefObject<PlaybackState>;
|
|
27
|
-
playheadRef: React.RefObject<HTMLDivElement>;
|
|
28
|
-
counterRef: React.RefObject<HTMLSpanElement>;
|
|
29
|
-
paused: boolean;
|
|
30
|
-
setPaused: (paused: boolean) => void;
|
|
31
|
-
speed: number;
|
|
32
|
-
setSpeed: (speed: number) => void;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const LABEL_WIDTH = 200;
|
|
36
|
-
const RULER_HEIGHT = 22;
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const [
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
next.
|
|
101
|
-
system.emitter.visible =
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
style={{
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
{
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
1
|
+
import React, {useMemo, useRef, useState} from 'react';
|
|
2
|
+
import type {ParticleSystem, BatchedRenderer} from 'babylon.quarks';
|
|
3
|
+
import type {Scene} from '@babylonjs/core/scene';
|
|
4
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
5
|
+
import type {EffectBinding} from '../core/binding';
|
|
6
|
+
import {groupNodes} from '../core/groups';
|
|
7
|
+
import {scrubTo} from '../core/scrub';
|
|
8
|
+
import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
|
|
9
|
+
import {TimelineTrackRow} from './TimelineTrackRow';
|
|
10
|
+
import {buttonStyle, theme} from './theme';
|
|
11
|
+
|
|
12
|
+
export interface PlaybackState {
|
|
13
|
+
paused: boolean;
|
|
14
|
+
speed: number;
|
|
15
|
+
stepQueued: boolean;
|
|
16
|
+
elapsed: number;
|
|
17
|
+
scrubbing: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TimelinePanelProps {
|
|
21
|
+
binding: EffectBinding;
|
|
22
|
+
selectedNode: TransformNode | null;
|
|
23
|
+
onSelectNode: (node: TransformNode | null) => void;
|
|
24
|
+
scene: Scene;
|
|
25
|
+
renderer: BatchedRenderer;
|
|
26
|
+
playbackRef: React.MutableRefObject<PlaybackState>;
|
|
27
|
+
playheadRef: React.RefObject<HTMLDivElement>;
|
|
28
|
+
counterRef: React.RefObject<HTMLSpanElement>;
|
|
29
|
+
paused: boolean;
|
|
30
|
+
setPaused: (paused: boolean) => void;
|
|
31
|
+
speed: number;
|
|
32
|
+
setSpeed: (speed: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LABEL_WIDTH = 200;
|
|
36
|
+
const RULER_HEIGHT = 22;
|
|
37
|
+
const STAGE_ROW_HEIGHT = 26;
|
|
38
|
+
const PANEL_HEIGHT = 220;
|
|
39
|
+
/** Small gap between the label column and the first pixel of the time axis. */
|
|
40
|
+
export const TIMELINE_INSET = 10;
|
|
41
|
+
/** Gap reserved at the right edge so the full-duration mark doesn't sit flush against the panel edge. */
|
|
42
|
+
const RIGHT_PADDING = 16;
|
|
43
|
+
|
|
44
|
+
function pickTickInterval(pxPerSecond: number): number {
|
|
45
|
+
if (pxPerSecond >= 60) return 1;
|
|
46
|
+
if (pxPerSecond >= 24) return 2;
|
|
47
|
+
if (pxPerSecond >= 10) return 5;
|
|
48
|
+
return 10;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bottom-docked, Premiere/Resolve-style timeline: transport controls, a time ruler with a
|
|
53
|
+
* draggable playhead (approximate scrub — see core/scrub.ts), and one track row per system.
|
|
54
|
+
*/
|
|
55
|
+
export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
|
|
56
|
+
const {binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, paused, setPaused, speed, setSpeed} = props;
|
|
57
|
+
|
|
58
|
+
const [bodyWidthPx, setBodyWidthPx] = useState(600);
|
|
59
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<TransformNode>>(() => new Set());
|
|
60
|
+
const [hiddenSystems, setHiddenSystems] = useState<Set<ParticleSystem>>(() => new Set());
|
|
61
|
+
const [groupSelection, setGroupSelection] = useState<Set<TransformNode>>(() => new Set());
|
|
62
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
63
|
+
const dragRef = useRef<{raf: number | null; pendingTime: number | null}>({raf: null, pendingTime: null});
|
|
64
|
+
|
|
65
|
+
// A new effect (import/undo/redo/initial mount) replaces every system, so stale
|
|
66
|
+
// visibility toggles and grouping selection from the previous effect no longer refer to
|
|
67
|
+
// anything real.
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
setHiddenSystems(new Set());
|
|
70
|
+
setGroupSelection(new Set());
|
|
71
|
+
}, [binding]);
|
|
72
|
+
|
|
73
|
+
const toggleGroupSelect = (node: TransformNode) => {
|
|
74
|
+
setGroupSelection((prev) => {
|
|
75
|
+
const next = new Set(prev);
|
|
76
|
+
if (next.has(node)) {
|
|
77
|
+
next.delete(node);
|
|
78
|
+
} else {
|
|
79
|
+
next.add(node);
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const groupSelectedNodes = () => {
|
|
86
|
+
if (groupSelection.size < 2) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const nodes = [...groupSelection];
|
|
90
|
+
binding.apply(() => {
|
|
91
|
+
groupNodes(nodes, `Group ${rows.filter((r) => r.kind === 'group').length + 1}`);
|
|
92
|
+
});
|
|
93
|
+
setGroupSelection(new Set());
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const toggleVisible = (system: ParticleSystem) => {
|
|
97
|
+
setHiddenSystems((prev) => {
|
|
98
|
+
const next = new Set(prev);
|
|
99
|
+
if (next.has(system)) {
|
|
100
|
+
next.delete(system);
|
|
101
|
+
system.emitter.visible = true;
|
|
102
|
+
} else {
|
|
103
|
+
next.add(system);
|
|
104
|
+
system.emitter.visible = false;
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
React.useLayoutEffect(() => {
|
|
111
|
+
const el = bodyRef.current;
|
|
112
|
+
if (!el) return;
|
|
113
|
+
const observer = new ResizeObserver((entries) => {
|
|
114
|
+
const width = entries[0]?.contentRect.width;
|
|
115
|
+
if (width) setBodyWidthPx(Math.max(1, width - LABEL_WIDTH));
|
|
116
|
+
});
|
|
117
|
+
observer.observe(el);
|
|
118
|
+
return () => observer.disconnect();
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const rows = useMemo(() => computeTimelineRows(binding), [binding, binding.getRevision()]);
|
|
122
|
+
// The ruler spans exactly the furthest track extent (no artificial stretch), minus a
|
|
123
|
+
// right-hand gap so the full-duration mark never sits flush against the panel edge.
|
|
124
|
+
const timelineSpan = useMemo(() => computeTimelineSpan(rows), [rows]);
|
|
125
|
+
const pxPerSecond = Math.max(1, bodyWidthPx - TIMELINE_INSET - RIGHT_PADDING) / timelineSpan;
|
|
126
|
+
|
|
127
|
+
// Follows playbackRef.elapsed independently of Babylon's render loop (Host only owns
|
|
128
|
+
// simulation time; this owns translating it into pixels for the playhead line). Whether
|
|
129
|
+
// the *whole* effect loops is an OR across tracks (there's no single canonical "root" —
|
|
130
|
+
// an effect can have several top-level systems): if any track loops, elapsed grows
|
|
131
|
+
// unbounded and we wrap it at timelineSpan. If none loop, finishing a run snaps playback
|
|
132
|
+
// straight back to frame 0 (paused) — a one-shot preview parked and ready for the next Play.
|
|
133
|
+
const anyLooping = useMemo(() => rows.some((r) => r.kind === 'track' && r.looping), [rows]);
|
|
134
|
+
// Tracks elapsed from the previous tick so the finish-reset below only fires on a genuine
|
|
135
|
+
// crossing during live playback. Without this, manually dragging the playhead to (or past)
|
|
136
|
+
// the end pins elapsed at timelineSpan while scrubbing=true skips the reset — but the very
|
|
137
|
+
// next tick after release would otherwise see elapsed >= timelineSpan with no memory of
|
|
138
|
+
// *how* it got there, and wrongly treat a drag as a finished run.
|
|
139
|
+
const prevElapsedRef = useRef(0);
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
let raf: number;
|
|
142
|
+
const tick = () => {
|
|
143
|
+
const state = playbackRef.current;
|
|
144
|
+
const elapsed = state.elapsed;
|
|
145
|
+
let shown = elapsed;
|
|
146
|
+
if (anyLooping) {
|
|
147
|
+
shown = timelineSpan > 0 ? elapsed % timelineSpan : 0;
|
|
148
|
+
} else if (elapsed >= timelineSpan) {
|
|
149
|
+
shown = timelineSpan;
|
|
150
|
+
const justFinishedPlaying = !state.scrubbing && !state.paused && prevElapsedRef.current < timelineSpan;
|
|
151
|
+
if (justFinishedPlaying) {
|
|
152
|
+
scrubTo(binding, renderer, 0);
|
|
153
|
+
state.elapsed = 0;
|
|
154
|
+
state.paused = true;
|
|
155
|
+
setPaused(true);
|
|
156
|
+
scene.render();
|
|
157
|
+
shown = 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
prevElapsedRef.current = elapsed;
|
|
161
|
+
if (playheadRef.current) {
|
|
162
|
+
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + shown * pxPerSecond}px`;
|
|
163
|
+
}
|
|
164
|
+
raf = requestAnimationFrame(tick);
|
|
165
|
+
};
|
|
166
|
+
raf = requestAnimationFrame(tick);
|
|
167
|
+
return () => cancelAnimationFrame(raf);
|
|
168
|
+
}, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, setPaused, binding, renderer, scene]);
|
|
169
|
+
|
|
170
|
+
const visibleRows = useMemo(() => {
|
|
171
|
+
let hideUntilDepth: number | null = null;
|
|
172
|
+
const visible = rows.filter((row) => {
|
|
173
|
+
if (hideUntilDepth !== null) {
|
|
174
|
+
if (row.depth > hideUntilDepth) return false;
|
|
175
|
+
hideUntilDepth = null;
|
|
176
|
+
}
|
|
177
|
+
if (row.kind === 'group' && collapsedGroups.has(row.node)) {
|
|
178
|
+
hideUntilDepth = row.depth;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
return visible;
|
|
183
|
+
}, [rows, collapsedGroups]);
|
|
184
|
+
|
|
185
|
+
const toggleCollapse = (node: TransformNode) => {
|
|
186
|
+
setCollapsedGroups((prev) => {
|
|
187
|
+
const next = new Set(prev);
|
|
188
|
+
if (next.has(node)) {
|
|
189
|
+
next.delete(node);
|
|
190
|
+
} else {
|
|
191
|
+
next.add(node);
|
|
192
|
+
}
|
|
193
|
+
return next;
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const pixelToTime = (clientX: number): number => {
|
|
198
|
+
const rect = bodyRef.current!.getBoundingClientRect();
|
|
199
|
+
const x = clientX - rect.left - LABEL_WIDTH - TIMELINE_INSET;
|
|
200
|
+
return Math.min(timelineSpan, Math.max(0, x / pxPerSecond));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const applyScrub = (time: number) => {
|
|
204
|
+
scrubTo(binding, renderer, time);
|
|
205
|
+
playbackRef.current.elapsed = time;
|
|
206
|
+
if (playheadRef.current) {
|
|
207
|
+
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + time * pxPerSecond}px`;
|
|
208
|
+
}
|
|
209
|
+
scene.render();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const queueScrub = (clientX: number) => {
|
|
213
|
+
dragRef.current.pendingTime = pixelToTime(clientX);
|
|
214
|
+
if (dragRef.current.raf == null) {
|
|
215
|
+
dragRef.current.raf = requestAnimationFrame(() => {
|
|
216
|
+
dragRef.current.raf = null;
|
|
217
|
+
const t = dragRef.current.pendingTime;
|
|
218
|
+
if (t != null) applyScrub(t);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const restart = () => {
|
|
224
|
+
applyScrub(0);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const ticks: number[] = [];
|
|
228
|
+
for (let t = 0; t <= timelineSpan; t += pickTickInterval(pxPerSecond)) {
|
|
229
|
+
ticks.push(t);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const overlayButton: React.CSSProperties = {...buttonStyle, padding: '5px 9px', minWidth: 32};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
style={{
|
|
237
|
+
flexShrink: 0,
|
|
238
|
+
height: PANEL_HEIGHT,
|
|
239
|
+
borderTop: `1px solid ${theme.border}`,
|
|
240
|
+
background: theme.panelBg,
|
|
241
|
+
display: 'flex',
|
|
242
|
+
flexDirection: 'column',
|
|
243
|
+
fontFamily: theme.font,
|
|
244
|
+
color: theme.text,
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<div style={{display: 'flex', alignItems: 'center', gap: 6, padding: '6px 10px', borderBottom: `1px solid ${theme.border}`}}>
|
|
248
|
+
<button className="qe-hover" style={overlayButton} title={paused ? 'Play' : 'Pause'} onClick={() => {
|
|
249
|
+
// A finished one-shot effect already snapped itself back to frame 0 and
|
|
250
|
+
// paused (see the tick effect above), so a plain toggle is enough here.
|
|
251
|
+
playbackRef.current.paused = !paused;
|
|
252
|
+
setPaused(!paused);
|
|
253
|
+
}}>
|
|
254
|
+
{paused ? '▶' : '⏸'}
|
|
255
|
+
</button>
|
|
256
|
+
<button className="qe-hover" style={overlayButton} title="Step one frame" disabled={!paused} onClick={() => (playbackRef.current.stepQueued = true)}>
|
|
257
|
+
⏭
|
|
258
|
+
</button>
|
|
259
|
+
<button className="qe-hover" style={overlayButton} title="Restart" onClick={restart}>
|
|
260
|
+
⟲
|
|
261
|
+
</button>
|
|
262
|
+
<select
|
|
263
|
+
className="qe-hover"
|
|
264
|
+
style={{...overlayButton, padding: '5px 4px'}}
|
|
265
|
+
title="Playback speed"
|
|
266
|
+
value={speed}
|
|
267
|
+
onChange={(e) => {
|
|
268
|
+
const next = Number(e.target.value);
|
|
269
|
+
playbackRef.current.speed = next;
|
|
270
|
+
setSpeed(next);
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
{[0.1, 0.25, 0.5, 1, 2, 4].map((s) => (
|
|
274
|
+
<option key={s} value={s}>
|
|
275
|
+
×{s}
|
|
276
|
+
</option>
|
|
277
|
+
))}
|
|
278
|
+
</select>
|
|
279
|
+
<span ref={counterRef} style={{fontSize: 12, color: theme.textDim, fontVariantNumeric: 'tabular-nums', paddingLeft: 4, minWidth: 120}} />
|
|
280
|
+
{groupSelection.size >= 2 && (
|
|
281
|
+
<button className="qe-hover" style={overlayButton} title="Group the checked rows" onClick={groupSelectedNodes}>
|
|
282
|
+
Group ({groupSelection.size})
|
|
283
|
+
</button>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
{/* position:relative wrapper whose box is exactly the visible panel body — the
|
|
287
|
+
ruler header/divider/playhead below are its direct children (siblings of the
|
|
288
|
+
scroller, not descendants), so top:0/bottom:0 always means "100% of the panel,"
|
|
289
|
+
independent of scroll position or how many tracks there are. Also measures the
|
|
290
|
+
full-width box (labels + time axis) for pxPerSecond, since the ruler and the
|
|
291
|
+
scroller share that width. */}
|
|
292
|
+
<div ref={bodyRef} style={{position: 'relative', flex: 1, minHeight: 0}}>
|
|
293
|
+
{/* Fixed ruler header, kept out of the vertical scroller so it never scrolls
|
|
294
|
+
away with the track rows. */}
|
|
295
|
+
<div
|
|
296
|
+
style={{
|
|
297
|
+
position: 'absolute',
|
|
298
|
+
top: 0,
|
|
299
|
+
left: 0,
|
|
300
|
+
right: 0,
|
|
301
|
+
height: RULER_HEIGHT,
|
|
302
|
+
display: 'grid',
|
|
303
|
+
gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
|
|
304
|
+
background: theme.panelBg,
|
|
305
|
+
zIndex: 2,
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<div style={{height: RULER_HEIGHT}} />
|
|
309
|
+
<div
|
|
310
|
+
className="qe-hover-bg"
|
|
311
|
+
style={{position: 'relative', height: RULER_HEIGHT, cursor: 'ew-resize', overflow: 'hidden'}}
|
|
312
|
+
onPointerDown={(e) => {
|
|
313
|
+
playbackRef.current.scrubbing = true;
|
|
314
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
315
|
+
queueScrub(e.clientX);
|
|
316
|
+
}}
|
|
317
|
+
onPointerMove={(e) => {
|
|
318
|
+
if (playbackRef.current.scrubbing) queueScrub(e.clientX);
|
|
319
|
+
}}
|
|
320
|
+
onPointerUp={(e) => {
|
|
321
|
+
playbackRef.current.scrubbing = false;
|
|
322
|
+
(e.target as Element).releasePointerCapture?.(e.pointerId);
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
{ticks.map((t) => (
|
|
326
|
+
<div key={t} style={{position: 'absolute', left: TIMELINE_INSET + t * pxPerSecond, top: 0, bottom: 0, borderLeft: `1px solid ${theme.border}`, fontSize: 10, color: theme.textDim, paddingLeft: 3}}>
|
|
327
|
+
{t}s
|
|
328
|
+
</div>
|
|
329
|
+
))}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
<div
|
|
333
|
+
className="qe-hover-bg"
|
|
334
|
+
title="Preview parent — simulates effect.parent = someNode; not saved in the exported JSON"
|
|
335
|
+
style={{
|
|
336
|
+
position: 'absolute',
|
|
337
|
+
top: RULER_HEIGHT,
|
|
338
|
+
left: 0,
|
|
339
|
+
right: 0,
|
|
340
|
+
height: STAGE_ROW_HEIGHT,
|
|
341
|
+
display: 'flex',
|
|
342
|
+
alignItems: 'center',
|
|
343
|
+
gap: 6,
|
|
344
|
+
padding: '0 8px',
|
|
345
|
+
fontSize: 12.5,
|
|
346
|
+
fontStyle: 'italic',
|
|
347
|
+
cursor: 'pointer',
|
|
348
|
+
color: selectedNode === null ? theme.text : theme.textDim,
|
|
349
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
350
|
+
...(selectedNode === null ? {background: 'rgba(60, 105, 209, 0.3)'} : {}),
|
|
351
|
+
}}
|
|
352
|
+
onClick={() => onSelectNode(null)}
|
|
353
|
+
>
|
|
354
|
+
<span style={{fontSize: 10, width: 10}}>◎</span>
|
|
355
|
+
<span>Stage (preview parent)</span>
|
|
356
|
+
</div>
|
|
357
|
+
<div
|
|
358
|
+
style={{
|
|
359
|
+
position: 'absolute',
|
|
360
|
+
top: RULER_HEIGHT + STAGE_ROW_HEIGHT,
|
|
361
|
+
left: 0,
|
|
362
|
+
right: 0,
|
|
363
|
+
bottom: 0,
|
|
364
|
+
overflowY: 'auto',
|
|
365
|
+
overflowX: 'hidden',
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
<div
|
|
369
|
+
style={{
|
|
370
|
+
display: 'grid',
|
|
371
|
+
gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
|
|
372
|
+
gridAutoRows: 'min-content',
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
{visibleRows.map((row) => (
|
|
376
|
+
<TimelineTrackRow
|
|
377
|
+
key={row.node.uniqueId}
|
|
378
|
+
row={row}
|
|
379
|
+
binding={binding}
|
|
380
|
+
selected={row.node === selectedNode}
|
|
381
|
+
onSelectNode={onSelectNode}
|
|
382
|
+
pxPerSecond={pxPerSecond}
|
|
383
|
+
offsetPx={TIMELINE_INSET}
|
|
384
|
+
collapsed={row.kind === 'group' && collapsedGroups.has(row.node)}
|
|
385
|
+
onToggleCollapse={row.kind === 'group' ? () => toggleCollapse(row.node) : undefined}
|
|
386
|
+
hidden={row.kind === 'track' && hiddenSystems.has(row.system)}
|
|
387
|
+
onToggleVisible={row.kind === 'track' ? () => toggleVisible(row.system) : undefined}
|
|
388
|
+
groupSelected={groupSelection.has(row.node)}
|
|
389
|
+
onToggleGroupSelect={() => toggleGroupSelect(row.node)}
|
|
390
|
+
/>
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
{/* Static divider between the label column and the time axis, distinct from the moving playhead. */}
|
|
395
|
+
<div style={{position: 'absolute', top: 0, bottom: 0, left: LABEL_WIDTH, width: 1, background: theme.border, pointerEvents: 'none', zIndex: 3}} />
|
|
396
|
+
<div
|
|
397
|
+
ref={playheadRef}
|
|
398
|
+
style={{
|
|
399
|
+
position: 'absolute',
|
|
400
|
+
top: 0,
|
|
401
|
+
bottom: 0,
|
|
402
|
+
left: LABEL_WIDTH + TIMELINE_INSET,
|
|
403
|
+
width: 1,
|
|
404
|
+
background: theme.accent,
|
|
405
|
+
pointerEvents: 'none',
|
|
406
|
+
zIndex: 3,
|
|
407
|
+
}}
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
}
|