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.
Files changed (37) hide show
  1. package/dist/core/timeline.d.ts +1 -1
  2. package/dist/core/timeline.js +3 -2
  3. package/dist/react/EffectEditor.d.ts +3 -0
  4. package/dist/react/EffectEditor.js +6 -1
  5. package/dist/react/EffectEditorHost.js +72 -11
  6. package/dist/react/ObjectModule.d.ts +6 -0
  7. package/dist/react/ObjectModule.js +28 -0
  8. package/dist/react/TimelinePanel.d.ts +4 -3
  9. package/dist/react/TimelinePanel.js +60 -10
  10. package/dist/react/TimelineTrackRow.d.ts +2 -2
  11. package/dist/react/TimelineTrackRow.js +16 -12
  12. package/dist/react/theme.js +44 -21
  13. package/package.json +68 -63
  14. package/src/core/binding.ts +105 -105
  15. package/src/core/collision.ts +40 -40
  16. package/src/core/groups.ts +29 -29
  17. package/src/core/loadEffect.ts +102 -102
  18. package/src/core/scrub.ts +28 -28
  19. package/src/core/shapes.ts +133 -133
  20. package/src/core/systems.ts +98 -98
  21. package/src/core/timeline.ts +95 -94
  22. package/src/core/values.ts +113 -113
  23. package/src/index.ts +32 -32
  24. package/src/react/CurveEditor.tsx +168 -168
  25. package/src/react/EffectEditor.tsx +125 -111
  26. package/src/react/EffectEditorHost.tsx +466 -377
  27. package/src/react/GradientEditor.tsx +127 -127
  28. package/src/react/ModuleSection.tsx +54 -54
  29. package/src/react/ObjectModule.tsx +47 -0
  30. package/src/react/PromptDialog.tsx +85 -85
  31. package/src/react/TimelinePanel.tsx +412 -354
  32. package/src/react/TimelineTrackRow.tsx +291 -267
  33. package/src/react/behaviorModules.tsx +658 -658
  34. package/src/react/fields.tsx +76 -76
  35. package/src/react/index.ts +43 -43
  36. package/src/react/modules.tsx +1034 -1034
  37. 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
- selectedSystem: ParticleSystem;
23
- onSelect: (system: ParticleSystem) => 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 PANEL_HEIGHT = 220;
38
- /** Small gap between the label column and the first pixel of the time axis. */
39
- export const TIMELINE_INSET = 10;
40
-
41
- function pickTickInterval(pxPerSecond: number): number {
42
- if (pxPerSecond >= 60) return 1;
43
- if (pxPerSecond >= 24) return 2;
44
- if (pxPerSecond >= 10) return 5;
45
- return 10;
46
- }
47
-
48
- /**
49
- * Bottom-docked, Premiere/Resolve-style timeline: transport controls, a time ruler with a
50
- * draggable playhead (approximate scrub — see core/scrub.ts), and one track row per system.
51
- */
52
- export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
53
- const {binding, selectedSystem, onSelect, scene, renderer, playbackRef, playheadRef, counterRef, paused, setPaused, speed, setSpeed} = props;
54
-
55
- const [bodyWidthPx, setBodyWidthPx] = useState(600);
56
- const [collapsedGroups, setCollapsedGroups] = useState<Set<TransformNode>>(() => new Set());
57
- const [hiddenSystems, setHiddenSystems] = useState<Set<ParticleSystem>>(() => new Set());
58
- const [groupSelection, setGroupSelection] = useState<Set<TransformNode>>(() => new Set());
59
- const bodyRef = useRef<HTMLDivElement>(null);
60
- const dragRef = useRef<{raf: number | null; pendingTime: number | null}>({raf: null, pendingTime: null});
61
-
62
- // A new effect (import/undo/redo/initial mount) replaces every system, so stale
63
- // visibility toggles and grouping selection from the previous effect no longer refer to
64
- // anything real.
65
- React.useEffect(() => {
66
- setHiddenSystems(new Set());
67
- setGroupSelection(new Set());
68
- }, [binding]);
69
-
70
- const toggleGroupSelect = (node: TransformNode) => {
71
- setGroupSelection((prev) => {
72
- const next = new Set(prev);
73
- if (next.has(node)) {
74
- next.delete(node);
75
- } else {
76
- next.add(node);
77
- }
78
- return next;
79
- });
80
- };
81
-
82
- const groupSelectedNodes = () => {
83
- if (groupSelection.size < 2) {
84
- return;
85
- }
86
- const nodes = [...groupSelection];
87
- binding.apply(() => {
88
- groupNodes(nodes, `Group ${rows.filter((r) => r.kind === 'group').length + 1}`);
89
- });
90
- setGroupSelection(new Set());
91
- };
92
-
93
- const toggleVisible = (system: ParticleSystem) => {
94
- setHiddenSystems((prev) => {
95
- const next = new Set(prev);
96
- if (next.has(system)) {
97
- next.delete(system);
98
- system.emitter.visible = true;
99
- } else {
100
- next.add(system);
101
- system.emitter.visible = false;
102
- }
103
- return next;
104
- });
105
- };
106
-
107
- React.useLayoutEffect(() => {
108
- const el = bodyRef.current;
109
- if (!el) return;
110
- const observer = new ResizeObserver((entries) => {
111
- const width = entries[0]?.contentRect.width;
112
- if (width) setBodyWidthPx(Math.max(1, width - LABEL_WIDTH));
113
- });
114
- observer.observe(el);
115
- return () => observer.disconnect();
116
- }, []);
117
-
118
- const rows = useMemo(() => computeTimelineRows(binding), [binding, binding.getRevision()]);
119
- const timelineSpan = useMemo(() => computeTimelineSpan(rows), [rows]);
120
- const pxPerSecond = Math.max(1, bodyWidthPx - TIMELINE_INSET) / timelineSpan;
121
-
122
- // Follows playbackRef.elapsed independently of Babylon's render loop (Host only owns
123
- // simulation time; this owns translating it into pixels for the playhead line). Looping
124
- // effects run forever, so elapsed grows unbounded — wrap it to the visible span instead
125
- // of letting the playhead fly off past the right edge.
126
- React.useEffect(() => {
127
- let raf: number;
128
- const tick = () => {
129
- if (playheadRef.current) {
130
- const wrapped = timelineSpan > 0 ? playbackRef.current.elapsed % timelineSpan : 0;
131
- playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + wrapped * pxPerSecond}px`;
132
- }
133
- raf = requestAnimationFrame(tick);
134
- };
135
- raf = requestAnimationFrame(tick);
136
- return () => cancelAnimationFrame(raf);
137
- }, [pxPerSecond, timelineSpan, playbackRef, playheadRef]);
138
-
139
- const visibleRows = useMemo(() => {
140
- let hideUntilDepth: number | null = null;
141
- const visible = rows.filter((row) => {
142
- if (hideUntilDepth !== null) {
143
- if (row.depth > hideUntilDepth) return false;
144
- hideUntilDepth = null;
145
- }
146
- if (row.kind === 'group' && collapsedGroups.has(row.node)) {
147
- hideUntilDepth = row.depth;
148
- }
149
- return true;
150
- });
151
- return visible;
152
- }, [rows, collapsedGroups]);
153
-
154
- const toggleCollapse = (node: TransformNode) => {
155
- setCollapsedGroups((prev) => {
156
- const next = new Set(prev);
157
- if (next.has(node)) {
158
- next.delete(node);
159
- } else {
160
- next.add(node);
161
- }
162
- return next;
163
- });
164
- };
165
-
166
- const pixelToTime = (clientX: number): number => {
167
- const rect = bodyRef.current!.getBoundingClientRect();
168
- const x = clientX - rect.left - LABEL_WIDTH - TIMELINE_INSET;
169
- return Math.min(timelineSpan, Math.max(0, x / pxPerSecond));
170
- };
171
-
172
- const applyScrub = (time: number) => {
173
- scrubTo(binding, renderer, time);
174
- playbackRef.current.elapsed = time;
175
- if (playheadRef.current) {
176
- playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + time * pxPerSecond}px`;
177
- }
178
- scene.render();
179
- };
180
-
181
- const queueScrub = (clientX: number) => {
182
- dragRef.current.pendingTime = pixelToTime(clientX);
183
- if (dragRef.current.raf == null) {
184
- dragRef.current.raf = requestAnimationFrame(() => {
185
- dragRef.current.raf = null;
186
- const t = dragRef.current.pendingTime;
187
- if (t != null) applyScrub(t);
188
- });
189
- }
190
- };
191
-
192
- const restart = () => {
193
- applyScrub(0);
194
- };
195
-
196
- const ticks: number[] = [];
197
- for (let t = 0; t <= timelineSpan; t += pickTickInterval(pxPerSecond)) {
198
- ticks.push(t);
199
- }
200
-
201
- const overlayButton: React.CSSProperties = {...buttonStyle, padding: '5px 9px', minWidth: 32};
202
-
203
- return (
204
- <div
205
- style={{
206
- flexShrink: 0,
207
- height: PANEL_HEIGHT,
208
- borderTop: `1px solid ${theme.border}`,
209
- background: theme.panelBg,
210
- display: 'flex',
211
- flexDirection: 'column',
212
- fontFamily: theme.font,
213
- color: theme.text,
214
- }}
215
- >
216
- <div style={{display: 'flex', alignItems: 'center', gap: 6, padding: '6px 10px', borderBottom: `1px solid ${theme.border}`}}>
217
- <button className="qe-hover" style={overlayButton} title={paused ? 'Play' : 'Pause'} onClick={() => {
218
- playbackRef.current.paused = !paused;
219
- setPaused(!paused);
220
- }}>
221
- {paused ? '▶' : '⏸'}
222
- </button>
223
- <button className="qe-hover" style={overlayButton} title="Step one frame" disabled={!paused} onClick={() => (playbackRef.current.stepQueued = true)}>
224
- ⏭
225
- </button>
226
- <button className="qe-hover" style={overlayButton} title="Restart" onClick={restart}>
227
- ⟲
228
- </button>
229
- <select
230
- className="qe-hover"
231
- style={{...overlayButton, padding: '5px 4px'}}
232
- title="Playback speed"
233
- value={speed}
234
- onChange={(e) => {
235
- const next = Number(e.target.value);
236
- playbackRef.current.speed = next;
237
- setSpeed(next);
238
- }}
239
- >
240
- {[0.1, 0.25, 0.5, 1, 2, 4].map((s) => (
241
- <option key={s} value={s}>
242
- ×{s}
243
- </option>
244
- ))}
245
- </select>
246
- <span ref={counterRef} style={{fontSize: 12, color: theme.textDim, fontVariantNumeric: 'tabular-nums', paddingLeft: 4, minWidth: 120}} />
247
- {groupSelection.size >= 2 && (
248
- <button className="qe-hover" style={overlayButton} title="Group the checked rows" onClick={groupSelectedNodes}>
249
- Group ({groupSelection.size})
250
- </button>
251
- )}
252
- </div>
253
- {/* position:relative wrapper whose box is exactly the visible panel body — the
254
- ruler header/divider/playhead below are its direct children (siblings of the
255
- scroller, not descendants), so top:0/bottom:0 always means "100% of the panel,"
256
- independent of scroll position or how many tracks there are. Also measures the
257
- full-width box (labels + time axis) for pxPerSecond, since the ruler and the
258
- scroller share that width. */}
259
- <div ref={bodyRef} style={{position: 'relative', flex: 1, minHeight: 0}}>
260
- {/* Fixed ruler header, kept out of the vertical scroller so it never scrolls
261
- away with the track rows. */}
262
- <div
263
- style={{
264
- position: 'absolute',
265
- top: 0,
266
- left: 0,
267
- right: 0,
268
- height: RULER_HEIGHT,
269
- display: 'grid',
270
- gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
271
- background: theme.panelBg,
272
- zIndex: 2,
273
- }}
274
- >
275
- <div style={{height: RULER_HEIGHT}} />
276
- <div
277
- className="qe-hover-bg"
278
- style={{position: 'relative', height: RULER_HEIGHT, cursor: 'ew-resize', overflow: 'hidden'}}
279
- onPointerDown={(e) => {
280
- playbackRef.current.scrubbing = true;
281
- (e.target as Element).setPointerCapture(e.pointerId);
282
- queueScrub(e.clientX);
283
- }}
284
- onPointerMove={(e) => {
285
- if (playbackRef.current.scrubbing) queueScrub(e.clientX);
286
- }}
287
- onPointerUp={(e) => {
288
- playbackRef.current.scrubbing = false;
289
- (e.target as Element).releasePointerCapture?.(e.pointerId);
290
- }}
291
- >
292
- {ticks.map((t) => (
293
- <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}}>
294
- {t}s
295
- </div>
296
- ))}
297
- </div>
298
- </div>
299
- <div
300
- style={{
301
- position: 'absolute',
302
- top: RULER_HEIGHT,
303
- left: 0,
304
- right: 0,
305
- bottom: 0,
306
- overflowY: 'auto',
307
- overflowX: 'hidden',
308
- }}
309
- >
310
- <div
311
- style={{
312
- display: 'grid',
313
- gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
314
- gridAutoRows: 'min-content',
315
- }}
316
- >
317
- {visibleRows.map((row) => (
318
- <TimelineTrackRow
319
- key={row.node.uniqueId}
320
- row={row}
321
- binding={binding}
322
- selected={row.kind === 'track' && row.system === selectedSystem}
323
- onSelect={onSelect}
324
- pxPerSecond={pxPerSecond}
325
- offsetPx={TIMELINE_INSET}
326
- collapsed={row.kind === 'group' && collapsedGroups.has(row.node)}
327
- onToggleCollapse={row.kind === 'group' ? () => toggleCollapse(row.node) : undefined}
328
- hidden={row.kind === 'track' && hiddenSystems.has(row.system)}
329
- onToggleVisible={row.kind === 'track' ? () => toggleVisible(row.system) : undefined}
330
- groupSelected={groupSelection.has(row.node)}
331
- onToggleGroupSelect={() => toggleGroupSelect(row.node)}
332
- />
333
- ))}
334
- </div>
335
- </div>
336
- {/* Static divider between the label column and the time axis, distinct from the moving playhead. */}
337
- <div style={{position: 'absolute', top: 0, bottom: 0, left: LABEL_WIDTH, width: 1, background: theme.border, pointerEvents: 'none', zIndex: 3}} />
338
- <div
339
- ref={playheadRef}
340
- style={{
341
- position: 'absolute',
342
- top: 0,
343
- bottom: 0,
344
- left: LABEL_WIDTH + TIMELINE_INSET,
345
- width: 1,
346
- background: theme.accent,
347
- pointerEvents: 'none',
348
- zIndex: 3,
349
- }}
350
- />
351
- </div>
352
- </div>
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
+ }