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
@@ -31,5 +31,5 @@ export declare function computeStartOffset(gen: ValueGenerator | FunctionValueGe
31
31
  };
32
32
  /** Flattens the effect's group/emitter tree into display rows, preserving tree-walk order. */
33
33
  export declare function computeTimelineRows(binding: EffectBinding): TimelineRow[];
34
- /** Overall visible timeline span: the furthest track extent, floored at a sane minimum. */
34
+ /** Overall timeline span: the furthest track extent (start offset + duration). */
35
35
  export declare function computeTimelineSpan(rows: TimelineRow[]): number;
@@ -1,5 +1,6 @@
1
1
  import { ConstantValue, IntervalValue } from 'babylon.quarks';
2
- const MIN_TIMELINE_SPAN = 4;
2
+ /** Guards against a literally zero-width ruler (e.g. no tracks yet) — not a usability stretch. */
3
+ const MIN_TIMELINE_SPAN = 0.01;
3
4
  /**
4
5
  * Reads a representative start-delay value for display without invoking the generator's
5
6
  * stateful `genValue` (IntervalValue caches its sampled slot on first call and returns NaN
@@ -57,7 +58,7 @@ export function computeTimelineRows(binding) {
57
58
  walk(binding.getTree());
58
59
  return rows;
59
60
  }
60
- /** Overall visible timeline span: the furthest track extent, floored at a sane minimum. */
61
+ /** Overall timeline span: the furthest track extent (start offset + duration). */
61
62
  export function computeTimelineSpan(rows) {
62
63
  let span = 0;
63
64
  for (const row of rows) {
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
3
  import { EffectBinding } from '../core/binding';
3
4
  import type { GeometryData, GeometryOption, TextureOption } from './modules';
4
5
  import type { ParticleSystem } from 'babylon.quarks';
@@ -6,6 +7,8 @@ export interface EffectEditorProps {
6
7
  binding: EffectBinding;
7
8
  /** Currently selected system, lifted up so the timeline panel and this module stack agree. */
8
9
  selectedSystem: ParticleSystem;
10
+ gizmoTargetNode: TransformNode;
11
+ gizmoTargetLabel: string;
9
12
  /** Texture presets offered in the Renderer module (host supplies the loader). */
10
13
  textureOptions?: TextureOption[];
11
14
  /** Mesh presets offered for the Mesh render mode (host supplies the geometry buffers). */
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useSyncExternalStore } from 'react';
3
3
  import { EffectBinding } from '../core/binding';
4
+ import { ObjectModule } from './ObjectModule';
4
5
  import { ColorOverLifeModule, EmissionModule, MainModule, RendererModule, ShapeModule, SizeOverLifeModule, } from './modules';
5
6
  import { CollisionModule, ColorBySpeedModule, EmitDirectionModule, ForceOverLifeModule, GravityModule, InheritVelocityModule, LimitSpeedOverLifeModule, NoiseModule, OrbitOverLifeModule, RotationBySpeedModule, RotationOverLifeModule, SizeBySpeedModule, SpeedOverLifeModule, TextureSheetModule, TurbulenceModule, VelocityOverLifeModule, WidthOverTrailModule, } from './behaviorModules';
6
7
  import { SubEmittersModule } from './SubEmittersModule';
@@ -26,5 +27,9 @@ export function EffectEditor(props) {
26
27
  return nested;
27
28
  }, [props.binding, activeSystem]);
28
29
  useSyncExternalStore((onStoreChange) => binding.subscribe(onStoreChange), () => binding.getRevision());
29
- return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_jsx(MainModule, { binding: binding }), _jsx(EmissionModule, { binding: binding }), _jsx(ShapeModule, { binding: binding }), _jsx(EmitDirectionModule, { binding: binding }), _jsx(VelocityOverLifeModule, { binding: binding }), _jsx(SpeedOverLifeModule, { binding: binding }), _jsx(LimitSpeedOverLifeModule, { binding: binding }), _jsx(InheritVelocityModule, { binding: binding }), _jsx(ForceOverLifeModule, { binding: binding }), _jsx(GravityModule, { binding: binding }), _jsx(OrbitOverLifeModule, { binding: binding }), _jsx(ColorOverLifeModule, { binding: binding }), _jsx(ColorBySpeedModule, { binding: binding }), _jsx(SizeOverLifeModule, { binding: binding }), _jsx(SizeBySpeedModule, { binding: binding }), _jsx(RotationOverLifeModule, { binding: binding }), _jsx(RotationBySpeedModule, { binding: binding }), _jsx(NoiseModule, { binding: binding }), _jsx(TurbulenceModule, { binding: binding }), _jsx(CollisionModule, { binding: binding }), _jsx(WidthOverTrailModule, { binding: binding }), _jsx(SubEmittersModule, { binding: binding, rootBinding: props.binding }), _jsx(TextureSheetModule, { binding: binding }), _jsx(RendererModule, { binding: binding, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture, resolveGeometry: props.resolveGeometry })] }));
30
+ const gizmoTargetIsEmitter = allSystems.some((s) => s.emitter === props.gizmoTargetNode);
31
+ if (!gizmoTargetIsEmitter) {
32
+ return (_jsx("div", { style: { fontFamily: theme.font, color: theme.text }, children: _jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel }) }));
33
+ }
34
+ return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel }), _jsx(MainModule, { binding: binding }), _jsx(EmissionModule, { binding: binding }), _jsx(ShapeModule, { binding: binding }), _jsx(EmitDirectionModule, { binding: binding }), _jsx(VelocityOverLifeModule, { binding: binding }), _jsx(SpeedOverLifeModule, { binding: binding }), _jsx(LimitSpeedOverLifeModule, { binding: binding }), _jsx(InheritVelocityModule, { binding: binding }), _jsx(ForceOverLifeModule, { binding: binding }), _jsx(GravityModule, { binding: binding }), _jsx(OrbitOverLifeModule, { binding: binding }), _jsx(ColorOverLifeModule, { binding: binding }), _jsx(ColorBySpeedModule, { binding: binding }), _jsx(SizeOverLifeModule, { binding: binding }), _jsx(SizeBySpeedModule, { binding: binding }), _jsx(RotationOverLifeModule, { binding: binding }), _jsx(RotationBySpeedModule, { binding: binding }), _jsx(NoiseModule, { binding: binding }), _jsx(TurbulenceModule, { binding: binding }), _jsx(CollisionModule, { binding: binding }), _jsx(WidthOverTrailModule, { binding: binding }), _jsx(SubEmittersModule, { binding: binding, rootBinding: props.binding }), _jsx(TextureSheetModule, { binding: binding }), _jsx(RendererModule, { binding: binding, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture, resolveGeometry: props.resolveGeometry })] }));
30
35
  }
@@ -9,6 +9,8 @@ import { Vector3 as BVector3 } from '@babylonjs/core/Maths/math.vector';
9
9
  import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
10
10
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
11
11
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
12
+ import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
13
+ import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
12
14
  import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
13
15
  import { BatchedRenderer, ColorOverLife, ConeEmitter, ConstantColor, ConstantValue, IntervalValue, ParticleSystem, RenderMode, Vector4, } from 'babylon.quarks';
14
16
  import { EffectBinding } from '../core/binding';
@@ -54,6 +56,26 @@ function disposeEffect(root, systems, renderer) {
54
56
  root.dispose(false, true);
55
57
  }
56
58
  }
59
+ const GIZMO_MODE_BUTTONS = [
60
+ { value: 'none', icon: '↖', title: 'Select (hide stage gizmo)' },
61
+ { value: 'position', icon: 'M', title: 'Move the stage node (position)' },
62
+ { value: 'rotation', icon: 'R', title: 'Rotate the stage node' },
63
+ { value: 'scale', icon: 'S', title: 'Scale the stage node' },
64
+ ];
65
+ const squareButtonStyle = {
66
+ ...buttonStyle,
67
+ width: 28,
68
+ height: 28,
69
+ padding: 0,
70
+ display: 'flex',
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ fontWeight: 700,
74
+ };
75
+ const activeSquareButtonStyle = {
76
+ border: `1px solid ${theme.borderActive}`,
77
+ background: 'rgba(120, 165, 255, 0.35)',
78
+ };
57
79
  /**
58
80
  * Self-contained effect editor: live Babylon preview + hierarchy/inspector sidebar.
59
81
  * Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
@@ -64,13 +86,15 @@ export function EffectEditorHost(props) {
64
86
  const playheadRef = useRef(null);
65
87
  const importRef = useRef(null);
66
88
  const stateRef = useRef(null);
89
+ const gizmoManagerRef = useRef(null);
67
90
  const [binding, setBinding] = useState(null);
68
91
  const [, force] = useReducer((x) => x + 1, 0);
69
92
  const playbackRef = useRef({ paused: false, speed: 1, stepQueued: false, elapsed: 0, scrubbing: false });
70
93
  const [paused, setPaused] = useState(false);
71
94
  const [speed, setSpeed] = useState(1);
72
- const [selectedSystem, setSelectedSystem] = useState(null);
73
95
  const [renamingRoot, setRenamingRoot] = useState(false);
96
+ const [gizmoMode, setGizmoMode] = useState('none');
97
+ const [selectedNode, setSelectedNode] = useState(null);
74
98
  useEffect(() => {
75
99
  // Register the shared ground-plane collider before any effect (default or imported)
76
100
  // is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
@@ -98,13 +122,21 @@ export function EffectEditorHost(props) {
98
122
  groundMaterial.opacity = 0.5;
99
123
  const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
100
124
  ground.material = groundMaterial;
125
+ const stageRoot = new TransformNode('StageRoot', scene);
126
+ const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
127
+ const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
128
+ gizmoManager.usePointerToAttachGizmos = false;
129
+ gizmoManager.attachToNode(stageRoot);
130
+ gizmoManagerRef.current = gizmoManager;
101
131
  const renderer = new BatchedRenderer('quarks-editor', scene);
102
132
  const history = new EffectHistory();
103
133
  const mount = (nextBinding) => {
134
+ nextBinding.root.parent = stageRoot;
104
135
  history.attach(nextBinding);
105
136
  nextBinding.subscribe(force);
106
- stateRef.current = { engine, scene, renderer, binding: nextBinding, history };
137
+ stateRef.current = { engine, scene, renderer, binding: nextBinding, history, stageRoot };
107
138
  setBinding(nextBinding);
139
+ setSelectedNode(nextBinding.system.emitter);
108
140
  props.onReady?.(makeHandle());
109
141
  };
110
142
  const makeHandle = () => ({
@@ -169,7 +201,8 @@ export function EffectEditorHost(props) {
169
201
  counterRef.current.textContent = `${count} particles · ${playback.elapsed.toFixed(2)}s`;
170
202
  }
171
203
  });
172
- const onResize = () => engine.resize();
204
+ const resizeObserver = new ResizeObserver(() => engine.resize());
205
+ resizeObserver.observe(canvasRef.current);
173
206
  const onKey = (e) => {
174
207
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
175
208
  e.preventDefault();
@@ -180,16 +213,43 @@ export function EffectEditorHost(props) {
180
213
  force();
181
214
  }
182
215
  };
183
- window.addEventListener('resize', onResize);
184
216
  window.addEventListener('keydown', onKey);
185
217
  return () => {
186
- window.removeEventListener('resize', onResize);
218
+ resizeObserver.disconnect();
187
219
  window.removeEventListener('keydown', onKey);
220
+ gizmoManager.dispose();
221
+ gizmoUtilityLayer.dispose();
188
222
  engine.dispose();
189
223
  };
190
224
  // eslint-disable-next-line react-hooks/exhaustive-deps
191
225
  }, []);
226
+ useEffect(() => {
227
+ const gm = gizmoManagerRef.current;
228
+ if (!gm)
229
+ return;
230
+ gm.positionGizmoEnabled = gizmoMode === 'position';
231
+ gm.rotationGizmoEnabled = gizmoMode === 'rotation';
232
+ gm.scaleGizmoEnabled = gizmoMode === 'scale';
233
+ const activeGizmo = gizmoMode === 'position' ? gm.gizmos.positionGizmo : gizmoMode === 'rotation' ? gm.gizmos.rotationGizmo : gizmoMode === 'scale' ? gm.gizmos.scaleGizmo : null;
234
+ const observer = activeGizmo?.onDragObservable.add(() => force());
235
+ return () => {
236
+ if (activeGizmo && observer)
237
+ activeGizmo.onDragObservable.remove(observer);
238
+ };
239
+ }, [gizmoMode]);
240
+ useEffect(() => {
241
+ const gm = gizmoManagerRef.current;
242
+ const stageRoot = stateRef.current?.stageRoot;
243
+ if (!gm || !stageRoot)
244
+ return;
245
+ if (selectedNode && selectedNode.isDisposed()) {
246
+ setSelectedNode(stateRef.current?.binding.system.emitter ?? null);
247
+ return;
248
+ }
249
+ gm.attachToNode(selectedNode ?? stageRoot);
250
+ }, [selectedNode, binding]);
192
251
  const state = stateRef.current;
252
+ const gizmoNode = selectedNode && !selectedNode.isDisposed() ? selectedNode : state?.stageRoot;
193
253
  const historyAction = (json) => {
194
254
  if (json && state) {
195
255
  const current = state.binding;
@@ -197,21 +257,22 @@ export function EffectEditorHost(props) {
197
257
  disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
198
258
  const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
199
259
  const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
260
+ next.root.parent = state.stageRoot;
200
261
  state.history.attach(next);
201
262
  next.subscribe(force);
202
263
  state.binding = next;
203
264
  setBinding(next);
265
+ setSelectedNode(next.system.emitter);
204
266
  }
205
267
  force();
206
268
  };
207
- // Selection survives re-renders but falls back to root if the system was removed/replaced
208
- // (e.g. after undo/redo or import swaps the whole binding out from under it).
209
269
  const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
210
- const activeSystem = binding ? (selectedSystem && allSystems.includes(selectedSystem) ? selectedSystem : binding.system) : null;
211
- return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', width: '100%', height: '100%', fontFamily: theme.font, color: theme.text, background: '#070b16' }, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsx("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, children: _jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }) }), _jsxs("aside", { style: { width: 340, flexShrink: 0, borderLeft: `1px solid ${theme.border}`, background: theme.panelBg, display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: { fontSize: 16, fontWeight: 600, cursor: binding ? 'pointer' : 'default', borderRadius: 6, padding: '2px 4px', margin: '-2px -4px' }, onClick: () => binding && setRenamingRoot(true), children: (binding?.root.name || props.title) ?? 'Effect editor' }), renamingRoot && binding && (_jsx(PromptDialog, { title: "Rename effect", defaultValue: binding.root.name, onSubmit: (name) => {
270
+ const activeSystem = binding ? (allSystems.find((s) => s.emitter === selectedNode) ?? binding.system) : null;
271
+ const gizmoTargetLabel = selectedNode ? gizmoNode?.name || 'Node' : 'Stage (preview parent)';
272
+ return (_jsxs("div", { className: "qe-root", style: { display: 'flex', flexDirection: 'column', width: '100%', height: '100%', fontFamily: theme.font, color: theme.text, background: '#070b16' }, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, children: [_jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }), _jsxs("div", { style: { position: 'absolute', top: 12, right: 12, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }, children: [_jsxs("div", { style: { display: 'flex', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, children: [_jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo, onClick: () => historyAction(state.history.undo()), children: "\u21B6" }), _jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo, onClick: () => historyAction(state.history.redo()), children: "\u21B7" })] }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, children: GIZMO_MODE_BUTTONS.map((m) => (_jsx("button", { className: "qe-hover", style: { ...squareButtonStyle, ...(gizmoMode === m.value ? activeSquareButtonStyle : {}) }, title: m.title, onClick: () => setGizmoMode(m.value), children: m.icon }, m.value))) })] })] }), _jsxs("aside", { style: { width: 340, flexShrink: 0, borderLeft: `1px solid ${theme.border}`, background: theme.panelBg, display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: { fontSize: 16, fontWeight: 600, cursor: binding ? 'pointer' : 'default', borderRadius: 6, padding: '2px 4px', margin: '-2px -4px' }, onClick: () => binding && setRenamingRoot(true), children: (binding?.root.name || props.title) ?? 'Effect editor' }), renamingRoot && binding && (_jsx(PromptDialog, { title: "Rename effect", defaultValue: binding.root.name, onSubmit: (name) => {
212
273
  binding.apply(() => (binding.root.name = name));
213
274
  setRenamingRoot(false);
214
- }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo, onClick: () => historyAction(state.history.undo()), children: "\u21B6" }), _jsx("button", { className: "qe-hover", style: buttonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo, onClick: () => historyAction(state.history.redo()), children: "\u21B7" }), _jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => {
275
+ }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => {
215
276
  if (!binding)
216
277
  return;
217
278
  const blob = new Blob([binding.exportJSON('EditorEffect')], { type: 'application/json' });
@@ -234,7 +295,7 @@ export function EffectEditorHost(props) {
234
295
  catch (err) {
235
296
  console.error('Failed to import effect JSON:', err);
236
297
  }
237
- } })] })] }), _jsx("div", { style: { flex: 1, overflowY: 'auto', padding: '6px 14px 20px' }, children: binding && activeSystem && (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture && state ? (url) => props.resolveTexture(url, state.scene) : undefined, resolveGeometry: props.resolveGeometry && state ? (file) => props.resolveGeometry(file, state.scene) : undefined })) })] })] }), binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedSystem: activeSystem, onSelect: setSelectedSystem, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, paused: paused, setPaused: setPaused, speed: speed, setSpeed: setSpeed }))] }));
298
+ } })] })] }), _jsx("div", { style: { flex: 1, overflowY: 'auto', padding: '6px 14px 20px' }, children: binding && activeSystem && gizmoNode && (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, gizmoTargetNode: gizmoNode, gizmoTargetLabel: gizmoTargetLabel, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture && state ? (url) => props.resolveTexture(url, state.scene) : undefined, resolveGeometry: props.resolveGeometry && state ? (file) => props.resolveGeometry(file, state.scene) : undefined })) })] })] }), binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, paused: paused, setPaused: setPaused, speed: speed, setSpeed: setSpeed }))] }));
238
299
  }
239
300
  /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
240
301
  export const QuarksEffectEditor = {
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
+ export declare function ObjectModule(props: {
4
+ node: TransformNode;
5
+ label: string;
6
+ }): React.JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Quaternion } from '@babylonjs/core/Maths/math.vector';
3
+ import { ModuleSection } from './ModuleSection';
4
+ import { NumberField, Row } from './fields';
5
+ const RAD_TO_DEG = 180 / Math.PI;
6
+ const DEG_TO_RAD = Math.PI / 180;
7
+ function getEulerDegrees(node) {
8
+ const euler = node.rotationQuaternion ? node.rotationQuaternion.toEulerAngles() : node.rotation;
9
+ return { x: euler.x * RAD_TO_DEG, y: euler.y * RAD_TO_DEG, z: euler.z * RAD_TO_DEG };
10
+ }
11
+ function setEulerDegreeAxis(node, axis, degrees) {
12
+ const euler = node.rotationQuaternion ? node.rotationQuaternion.toEulerAngles() : node.rotation.clone();
13
+ euler[axis] = degrees * DEG_TO_RAD;
14
+ if (node.rotationQuaternion) {
15
+ node.rotationQuaternion = Quaternion.FromEulerVector(euler);
16
+ }
17
+ else {
18
+ node.rotation = euler;
19
+ }
20
+ }
21
+ function AxisRow(props) {
22
+ return (_jsx(Row, { label: props.label, children: _jsxs("div", { style: { display: 'flex', gap: 4 }, children: [_jsx(NumberField, { value: props.values.x, step: props.step, onChange: (v) => props.onChange('x', v) }), _jsx(NumberField, { value: props.values.y, step: props.step, onChange: (v) => props.onChange('y', v) }), _jsx(NumberField, { value: props.values.z, step: props.step, onChange: (v) => props.onChange('z', v) })] }) }));
23
+ }
24
+ export function ObjectModule(props) {
25
+ const { node } = props;
26
+ const euler = getEulerDegrees(node);
27
+ return (_jsxs(ModuleSection, { title: `Object — ${props.label}`, children: [_jsx(AxisRow, { label: "Position", values: { x: node.position.x, y: node.position.y, z: node.position.z }, step: 0.1, onChange: (axis, v) => (node.position[axis] = v) }), _jsx(AxisRow, { label: "Rotation", values: euler, step: 1, onChange: (axis, v) => setEulerDegreeAxis(node, axis, v) }), _jsx(AxisRow, { label: "Scale", values: { x: node.scaling.x, y: node.scaling.y, z: node.scaling.z }, step: 0.1, onChange: (axis, v) => (node.scaling[axis] = v) })] }));
28
+ }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
- import type { ParticleSystem, BatchedRenderer } from 'babylon.quarks';
2
+ import type { BatchedRenderer } from 'babylon.quarks';
3
3
  import type { Scene } from '@babylonjs/core/scene';
4
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
4
5
  import type { EffectBinding } from '../core/binding';
5
6
  export interface PlaybackState {
6
7
  paused: boolean;
@@ -11,8 +12,8 @@ export interface PlaybackState {
11
12
  }
12
13
  export interface TimelinePanelProps {
13
14
  binding: EffectBinding;
14
- selectedSystem: ParticleSystem;
15
- onSelect: (system: ParticleSystem) => void;
15
+ selectedNode: TransformNode | null;
16
+ onSelectNode: (node: TransformNode | null) => void;
16
17
  scene: Scene;
17
18
  renderer: BatchedRenderer;
18
19
  playbackRef: React.MutableRefObject<PlaybackState>;
@@ -7,9 +7,12 @@ import { TimelineTrackRow } from './TimelineTrackRow';
7
7
  import { buttonStyle, theme } from './theme';
8
8
  const LABEL_WIDTH = 200;
9
9
  const RULER_HEIGHT = 22;
10
+ const STAGE_ROW_HEIGHT = 26;
10
11
  const PANEL_HEIGHT = 220;
11
12
  /** Small gap between the label column and the first pixel of the time axis. */
12
13
  export const TIMELINE_INSET = 10;
14
+ /** Gap reserved at the right edge so the full-duration mark doesn't sit flush against the panel edge. */
15
+ const RIGHT_PADDING = 16;
13
16
  function pickTickInterval(pxPerSecond) {
14
17
  if (pxPerSecond >= 60)
15
18
  return 1;
@@ -24,7 +27,7 @@ function pickTickInterval(pxPerSecond) {
24
27
  * draggable playhead (approximate scrub — see core/scrub.ts), and one track row per system.
25
28
  */
26
29
  export function TimelinePanel(props) {
27
- const { binding, selectedSystem, onSelect, scene, renderer, playbackRef, playheadRef, counterRef, paused, setPaused, speed, setSpeed } = props;
30
+ const { binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, paused, setPaused, speed, setSpeed } = props;
28
31
  const [bodyWidthPx, setBodyWidthPx] = useState(600);
29
32
  const [collapsedGroups, setCollapsedGroups] = useState(() => new Set());
30
33
  const [hiddenSystems, setHiddenSystems] = useState(() => new Set());
@@ -87,24 +90,53 @@ export function TimelinePanel(props) {
87
90
  return () => observer.disconnect();
88
91
  }, []);
89
92
  const rows = useMemo(() => computeTimelineRows(binding), [binding, binding.getRevision()]);
93
+ // The ruler spans exactly the furthest track extent (no artificial stretch), minus a
94
+ // right-hand gap so the full-duration mark never sits flush against the panel edge.
90
95
  const timelineSpan = useMemo(() => computeTimelineSpan(rows), [rows]);
91
- const pxPerSecond = Math.max(1, bodyWidthPx - TIMELINE_INSET) / timelineSpan;
96
+ const pxPerSecond = Math.max(1, bodyWidthPx - TIMELINE_INSET - RIGHT_PADDING) / timelineSpan;
92
97
  // Follows playbackRef.elapsed independently of Babylon's render loop (Host only owns
93
- // simulation time; this owns translating it into pixels for the playhead line). Looping
94
- // effects run forever, so elapsed grows unbounded — wrap it to the visible span instead
95
- // of letting the playhead fly off past the right edge.
98
+ // simulation time; this owns translating it into pixels for the playhead line). Whether
99
+ // the *whole* effect loops is an OR across tracks (there's no single canonical "root" —
100
+ // an effect can have several top-level systems): if any track loops, elapsed grows
101
+ // unbounded and we wrap it at timelineSpan. If none loop, finishing a run snaps playback
102
+ // straight back to frame 0 (paused) — a one-shot preview parked and ready for the next Play.
103
+ const anyLooping = useMemo(() => rows.some((r) => r.kind === 'track' && r.looping), [rows]);
104
+ // Tracks elapsed from the previous tick so the finish-reset below only fires on a genuine
105
+ // crossing during live playback. Without this, manually dragging the playhead to (or past)
106
+ // the end pins elapsed at timelineSpan while scrubbing=true skips the reset — but the very
107
+ // next tick after release would otherwise see elapsed >= timelineSpan with no memory of
108
+ // *how* it got there, and wrongly treat a drag as a finished run.
109
+ const prevElapsedRef = useRef(0);
96
110
  React.useEffect(() => {
97
111
  let raf;
98
112
  const tick = () => {
113
+ const state = playbackRef.current;
114
+ const elapsed = state.elapsed;
115
+ let shown = elapsed;
116
+ if (anyLooping) {
117
+ shown = timelineSpan > 0 ? elapsed % timelineSpan : 0;
118
+ }
119
+ else if (elapsed >= timelineSpan) {
120
+ shown = timelineSpan;
121
+ const justFinishedPlaying = !state.scrubbing && !state.paused && prevElapsedRef.current < timelineSpan;
122
+ if (justFinishedPlaying) {
123
+ scrubTo(binding, renderer, 0);
124
+ state.elapsed = 0;
125
+ state.paused = true;
126
+ setPaused(true);
127
+ scene.render();
128
+ shown = 0;
129
+ }
130
+ }
131
+ prevElapsedRef.current = elapsed;
99
132
  if (playheadRef.current) {
100
- const wrapped = timelineSpan > 0 ? playbackRef.current.elapsed % timelineSpan : 0;
101
- playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + wrapped * pxPerSecond}px`;
133
+ playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + shown * pxPerSecond}px`;
102
134
  }
103
135
  raf = requestAnimationFrame(tick);
104
136
  };
105
137
  raf = requestAnimationFrame(tick);
106
138
  return () => cancelAnimationFrame(raf);
107
- }, [pxPerSecond, timelineSpan, playbackRef, playheadRef]);
139
+ }, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, setPaused, binding, renderer, scene]);
108
140
  const visibleRows = useMemo(() => {
109
141
  let hideUntilDepth = null;
110
142
  const visible = rows.filter((row) => {
@@ -174,6 +206,8 @@ export function TimelinePanel(props) {
174
206
  fontFamily: theme.font,
175
207
  color: theme.text,
176
208
  }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '6px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("button", { className: "qe-hover", style: overlayButton, title: paused ? 'Play' : 'Pause', onClick: () => {
209
+ // A finished one-shot effect already snapped itself back to frame 0 and
210
+ // paused (see the tick effect above), so a plain toggle is enough here.
177
211
  playbackRef.current.paused = !paused;
178
212
  setPaused(!paused);
179
213
  }, children: paused ? '▶' : '⏸' }), _jsx("button", { className: "qe-hover", style: overlayButton, title: "Step one frame", disabled: !paused, onClick: () => (playbackRef.current.stepQueued = true), children: "\u23ED" }), _jsx("button", { className: "qe-hover", style: overlayButton, title: "Restart", onClick: restart, children: "\u27F2" }), _jsx("select", { className: "qe-hover", style: { ...overlayButton, padding: '5px 4px' }, title: "Playback speed", value: speed, onChange: (e) => {
@@ -200,11 +234,27 @@ export function TimelinePanel(props) {
200
234
  }, onPointerUp: (e) => {
201
235
  playbackRef.current.scrubbing = false;
202
236
  e.target.releasePointerCapture?.(e.pointerId);
203
- }, children: ticks.map((t) => (_jsxs("div", { style: { position: 'absolute', left: TIMELINE_INSET + t * pxPerSecond, top: 0, bottom: 0, borderLeft: `1px solid ${theme.border}`, fontSize: 10, color: theme.textDim, paddingLeft: 3 }, children: [t, "s"] }, t))) })] }), _jsx("div", { style: {
237
+ }, children: ticks.map((t) => (_jsxs("div", { style: { position: 'absolute', left: TIMELINE_INSET + t * pxPerSecond, top: 0, bottom: 0, borderLeft: `1px solid ${theme.border}`, fontSize: 10, color: theme.textDim, paddingLeft: 3 }, children: [t, "s"] }, t))) })] }), _jsxs("div", { className: "qe-hover-bg", title: "Preview parent \u2014 simulates effect.parent = someNode; not saved in the exported JSON", style: {
204
238
  position: 'absolute',
205
239
  top: RULER_HEIGHT,
206
240
  left: 0,
207
241
  right: 0,
242
+ height: STAGE_ROW_HEIGHT,
243
+ display: 'flex',
244
+ alignItems: 'center',
245
+ gap: 6,
246
+ padding: '0 8px',
247
+ fontSize: 12.5,
248
+ fontStyle: 'italic',
249
+ cursor: 'pointer',
250
+ color: selectedNode === null ? theme.text : theme.textDim,
251
+ borderBottom: `1px solid ${theme.border}`,
252
+ ...(selectedNode === null ? { background: 'rgba(60, 105, 209, 0.3)' } : {}),
253
+ }, onClick: () => onSelectNode(null), children: [_jsx("span", { style: { fontSize: 10, width: 10 }, children: "\u25CE" }), _jsx("span", { children: "Stage (preview parent)" })] }), _jsx("div", { style: {
254
+ position: 'absolute',
255
+ top: RULER_HEIGHT + STAGE_ROW_HEIGHT,
256
+ left: 0,
257
+ right: 0,
208
258
  bottom: 0,
209
259
  overflowY: 'auto',
210
260
  overflowX: 'hidden',
@@ -212,7 +262,7 @@ export function TimelinePanel(props) {
212
262
  display: 'grid',
213
263
  gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
214
264
  gridAutoRows: 'min-content',
215
- }, children: visibleRows.map((row) => (_jsx(TimelineTrackRow, { row: row, binding: binding, selected: row.kind === 'track' && row.system === selectedSystem, onSelect: onSelect, pxPerSecond: pxPerSecond, offsetPx: TIMELINE_INSET, collapsed: row.kind === 'group' && collapsedGroups.has(row.node), onToggleCollapse: row.kind === 'group' ? () => toggleCollapse(row.node) : undefined, hidden: row.kind === 'track' && hiddenSystems.has(row.system), onToggleVisible: row.kind === 'track' ? () => toggleVisible(row.system) : undefined, groupSelected: groupSelection.has(row.node), onToggleGroupSelect: () => toggleGroupSelect(row.node) }, row.node.uniqueId))) }) }), _jsx("div", { style: { position: 'absolute', top: 0, bottom: 0, left: LABEL_WIDTH, width: 1, background: theme.border, pointerEvents: 'none', zIndex: 3 } }), _jsx("div", { ref: playheadRef, style: {
265
+ }, children: visibleRows.map((row) => (_jsx(TimelineTrackRow, { row: row, binding: binding, selected: row.node === selectedNode, onSelectNode: onSelectNode, pxPerSecond: pxPerSecond, offsetPx: TIMELINE_INSET, collapsed: row.kind === 'group' && collapsedGroups.has(row.node), onToggleCollapse: row.kind === 'group' ? () => toggleCollapse(row.node) : undefined, hidden: row.kind === 'track' && hiddenSystems.has(row.system), onToggleVisible: row.kind === 'track' ? () => toggleVisible(row.system) : undefined, groupSelected: groupSelection.has(row.node), onToggleGroupSelect: () => toggleGroupSelect(row.node) }, row.node.uniqueId))) }) }), _jsx("div", { style: { position: 'absolute', top: 0, bottom: 0, left: LABEL_WIDTH, width: 1, background: theme.border, pointerEvents: 'none', zIndex: 3 } }), _jsx("div", { ref: playheadRef, style: {
216
266
  position: 'absolute',
217
267
  top: 0,
218
268
  bottom: 0,
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
- import type { ParticleSystem } from 'babylon.quarks';
2
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
3
  import type { EffectBinding } from '../core/binding';
4
4
  import type { TimelineRow } from '../core/timeline';
5
5
  export interface TimelineTrackRowProps {
6
6
  row: TimelineRow;
7
7
  binding: EffectBinding;
8
8
  selected: boolean;
9
- onSelect: (system: ParticleSystem) => void;
9
+ onSelectNode: (node: TransformNode | null) => void;
10
10
  pxPerSecond: number;
11
11
  /** Matches the ruler's left gutter so bars line up under the correct tick. */
12
12
  offsetPx: number;
@@ -10,7 +10,7 @@ const ROW_HEIGHT = 28;
10
10
  const checkboxButton = { ...iconButton, fontSize: 12, width: 12 };
11
11
  /** One row of the timeline: a label cell (name + actions) and, for tracks, a bar cell. */
12
12
  export function TimelineTrackRow(props) {
13
- const { row, binding, selected, onSelect, pxPerSecond, offsetPx, collapsed, onToggleCollapse, hidden, onToggleVisible, groupSelected, onToggleGroupSelect } = props;
13
+ const { row, binding, selected, onSelectNode, pxPerSecond, offsetPx, collapsed, onToggleCollapse, hidden, onToggleVisible, groupSelected, onToggleGroupSelect } = props;
14
14
  const [renaming, setRenaming] = useState(false);
15
15
  const isRoot = row.node === binding.root;
16
16
  const labelStyle = {
@@ -23,17 +23,21 @@ export function TimelineTrackRow(props) {
23
23
  borderRadius: 6,
24
24
  cursor: row.kind === 'track' ? 'pointer' : onToggleCollapse ? 'pointer' : 'default',
25
25
  fontSize: 12.5,
26
- color: row.kind === 'track' && selected ? theme.text : theme.textDim,
26
+ color: selected ? theme.text : theme.textDim,
27
27
  whiteSpace: 'nowrap',
28
+ boxSizing: 'border-box',
28
29
  // Left unset (rather than 'transparent') when not selected so the .qe-row-bg hover
29
30
  // tint isn't shadowed by an inline background of the same property.
30
- ...(row.kind === 'track' && selected ? { background: 'rgba(60, 105, 209, 0.3)' } : {}),
31
+ ...(selected ? { background: 'rgba(60, 105, 209, 0.3)' } : {}),
31
32
  };
32
33
  if (row.kind === 'group') {
33
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "qe-hover-bg", style: { ...labelStyle, borderTop: `1px solid ${theme.border}` }, onClick: onToggleCollapse, children: [!isRoot && (_jsx("button", { className: "qe-hover", title: groupSelected ? 'Remove from group selection' : 'Select for grouping', style: { ...checkboxButton, color: groupSelected ? theme.accent : theme.textDim }, onClick: (e) => {
34
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "qe-hover-bg", style: { ...labelStyle, borderTop: `1px solid ${theme.border}` }, onClick: () => onSelectNode(row.node), children: [!isRoot && (_jsx("button", { className: "qe-hover", title: groupSelected ? 'Remove from group selection' : 'Select for grouping', style: { ...checkboxButton, color: groupSelected ? theme.accent : theme.textDim }, onClick: (e) => {
34
35
  e.stopPropagation();
35
36
  onToggleGroupSelect();
36
- }, children: groupSelected ? '☑' : '☐' })), _jsx("span", { style: { color: theme.textDim, fontSize: 10, width: 10 }, children: collapsed ? '▸' : '▾' }), _jsx("span", { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }, children: row.name }), _jsx("button", { className: "qe-hover", title: "Add system to this group", style: { ...iconButton, color: theme.accent, fontSize: 13 }, onClick: (e) => {
37
+ }, children: groupSelected ? '☑' : '☐' })), _jsx("span", { className: "qe-hover", title: collapsed ? 'Expand' : 'Collapse', style: { color: theme.textDim, fontSize: 10, width: 10, cursor: 'pointer' }, onClick: (e) => {
38
+ e.stopPropagation();
39
+ onToggleCollapse?.();
40
+ }, children: collapsed ? '▸' : '▾' }), _jsx("span", { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }, children: row.name }), _jsx("button", { className: "qe-hover", title: "Add system to this group", style: { ...iconButton, color: theme.accent, fontSize: 13 }, onClick: (e) => {
37
41
  e.stopPropagation();
38
42
  let created;
39
43
  binding.apply(() => {
@@ -45,7 +49,7 @@ export function TimelineTrackRow(props) {
45
49
  });
46
50
  if (created) {
47
51
  binding.addSubSystem(created);
48
- onSelect(created);
52
+ onSelectNode(created.emitter);
49
53
  }
50
54
  }, children: "+" }), !isRoot && (_jsx("button", { className: "qe-hover", title: "Rename", style: { ...iconButton, color: theme.textDim }, onClick: (e) => {
51
55
  e.stopPropagation();
@@ -55,9 +59,9 @@ export function TimelineTrackRow(props) {
55
59
  binding.apply(() => ungroupNode(row.node));
56
60
  }, children: "\u26F6" })), !isRoot && (_jsx("button", { className: "qe-hover", title: "Remove group", style: { ...iconButton, color: '#e08c8c', fontSize: 12 }, onClick: (e) => {
57
61
  e.stopPropagation();
58
- removeSystems(binding, collectSystems(buildEffectTree(row.node)), onSelect);
62
+ removeSystems(binding, collectSystems(buildEffectTree(row.node)), (s) => onSelectNode(s.emitter));
59
63
  binding.apply(() => row.node.dispose(true, false));
60
- }, children: "\u2715" }))] }), _jsx("div", { className: "qe-hover-bg", style: { height: ROW_HEIGHT, borderTop: `1px solid ${theme.border}` }, onClick: onToggleCollapse }), renaming && (_jsx(PromptDialog, { title: "Rename", defaultValue: row.name, onSubmit: (name) => {
64
+ }, children: "\u2715" }))] }), _jsx("div", { className: "qe-hover-bg", style: { height: ROW_HEIGHT, borderTop: `1px solid ${theme.border}` }, onClick: () => onSelectNode(row.node) }), renaming && (_jsx(PromptDialog, { title: "Rename", defaultValue: row.name, onSubmit: (name) => {
61
65
  binding.apply(() => (row.node.name = name));
62
66
  setRenaming(false);
63
67
  }, onCancel: () => setRenaming(false) }))] }));
@@ -65,7 +69,7 @@ export function TimelineTrackRow(props) {
65
69
  const { system } = row;
66
70
  const barLeft = offsetPx + row.startOffset * pxPerSecond;
67
71
  const barWidth = Math.max(2, row.duration * pxPerSecond);
68
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "qe-hover-bg", style: { ...labelStyle, opacity: hidden ? 0.5 : 1 }, onClick: () => onSelect(system), children: [!isRoot && (_jsx("button", { className: "qe-hover", title: groupSelected ? 'Remove from group selection' : 'Select for grouping', style: { ...checkboxButton, color: groupSelected ? theme.accent : theme.textDim }, onClick: (e) => {
72
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "qe-hover-bg", style: { ...labelStyle, opacity: hidden ? 0.5 : 1 }, onClick: () => onSelectNode(row.node), children: [!isRoot && (_jsx("button", { className: "qe-hover", title: groupSelected ? 'Remove from group selection' : 'Select for grouping', style: { ...checkboxButton, color: groupSelected ? theme.accent : theme.textDim }, onClick: (e) => {
69
73
  e.stopPropagation();
70
74
  onToggleGroupSelect();
71
75
  }, children: groupSelected ? '☑' : '☐' })), _jsx("button", { className: "qe-hover", title: hidden ? 'Show in viewport' : 'Hide in viewport', style: { ...iconButton, color: hidden ? theme.textDim : theme.accent, fontSize: 12 }, onClick: (e) => {
@@ -76,7 +80,7 @@ export function TimelineTrackRow(props) {
76
80
  setRenaming(true);
77
81
  }, children: "\u270E" })), !isRoot && row.removable && (_jsx("button", { className: "qe-hover", title: "Remove system", style: { ...iconButton, color: '#e08c8c', fontSize: 12 }, onClick: (e) => {
78
82
  e.stopPropagation();
79
- removeSystems(binding, [system], onSelect);
83
+ removeSystems(binding, [system], (s) => onSelectNode(s.emitter));
80
84
  }, children: "\u2715" })), selected && (_jsx("button", { className: "qe-hover", title: "Add child system", style: { ...iconButton, color: theme.accent, fontSize: 13 }, onClick: (e) => {
81
85
  e.stopPropagation();
82
86
  let created;
@@ -86,9 +90,9 @@ export function TimelineTrackRow(props) {
86
90
  });
87
91
  if (created) {
88
92
  binding.addSubSystem(created);
89
- onSelect(created);
93
+ onSelectNode(created.emitter);
90
94
  }
91
- }, children: "+" }))] }), _jsx("div", { className: "qe-hover-bg", style: { position: 'relative', height: ROW_HEIGHT }, onClick: () => onSelect(system), children: _jsx("div", { className: "qe-hover", title: row.startOffsetVariable ? 'Start time varies per spawn' : undefined, style: {
95
+ }, children: "+" }))] }), _jsx("div", { className: "qe-hover-bg", style: { position: 'relative', height: ROW_HEIGHT }, onClick: () => onSelectNode(row.node), children: _jsx("div", { className: "qe-hover", title: row.startOffsetVariable ? 'Start time varies per spawn' : undefined, style: {
92
96
  position: 'absolute',
93
97
  left: barLeft,
94
98
  width: barWidth,
@@ -54,25 +54,48 @@ export const buttonStyle = {
54
54
  * rows), since brightening transparent does nothing. Must not be combined with an inline
55
55
  * `background` on the same element or the tint would need `!important` to show through.
56
56
  */
57
- export const globalEditorStyles = `
58
- .qe-hover, .qe-hover-bg {
59
- transition: filter 0.12s ease, background 0.12s ease;
60
- }
61
- .qe-hover:hover:not(:disabled) {
62
- filter: brightness(1.3);
63
- }
64
- .qe-hover:active:not(:disabled) {
65
- filter: brightness(0.85);
66
- }
67
- .qe-hover:disabled {
68
- opacity: 0.45;
69
- cursor: default;
70
- }
71
- .qe-hover-bg:hover {
72
- background: rgba(255, 255, 255, 0.07);
73
- }
74
- select.qe-hover:hover, input.qe-hover:hover, select.qe-hover:focus, input.qe-hover:focus {
75
- border-color: ${theme.borderActive};
76
- filter: none;
77
- }
57
+ export const globalEditorStyles = `
58
+ .qe-hover, .qe-hover-bg {
59
+ transition: filter 0.12s ease, background 0.12s ease;
60
+ }
61
+ .qe-hover:hover:not(:disabled) {
62
+ filter: brightness(1.3);
63
+ }
64
+ .qe-hover:active:not(:disabled) {
65
+ filter: brightness(0.85);
66
+ }
67
+ .qe-hover:disabled {
68
+ opacity: 0.45;
69
+ cursor: default;
70
+ }
71
+ .qe-hover-bg:hover {
72
+ background: rgba(255, 255, 255, 0.07);
73
+ }
74
+ select.qe-hover:hover, input.qe-hover:hover, select.qe-hover:focus, input.qe-hover:focus {
75
+ border-color: ${theme.borderActive};
76
+ filter: none;
77
+ }
78
+ .qe-root, .qe-root * {
79
+ scrollbar-width: thin;
80
+ scrollbar-color: ${theme.border} transparent;
81
+ }
82
+ .qe-root ::-webkit-scrollbar {
83
+ width: 10px;
84
+ height: 10px;
85
+ }
86
+ .qe-root ::-webkit-scrollbar-track {
87
+ background: transparent;
88
+ }
89
+ .qe-root ::-webkit-scrollbar-thumb {
90
+ background-color: ${theme.border};
91
+ background-clip: padding-box;
92
+ border: 2px solid transparent;
93
+ border-radius: 8px;
94
+ }
95
+ .qe-root ::-webkit-scrollbar-thumb:hover {
96
+ background-color: ${theme.borderActive};
97
+ }
98
+ .qe-root ::-webkit-scrollbar-corner {
99
+ background: transparent;
100
+ }
78
101
  `;