babylon.quarks-editor 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/assetDragSession.d.ts +4 -0
  3. package/dist/core/assetDragSession.js +8 -0
  4. package/dist/core/assetTree.d.ts +39 -0
  5. package/dist/core/assetTree.js +112 -0
  6. package/dist/core/assets.d.ts +56 -0
  7. package/dist/core/assets.js +207 -0
  8. package/dist/core/binding.d.ts +5 -0
  9. package/dist/core/binding.js +7 -0
  10. package/dist/core/collision.d.ts +4 -5
  11. package/dist/core/collision.js +4 -5
  12. package/dist/core/defaultEffect.d.ts +4 -5
  13. package/dist/core/defaultEffect.js +5 -14
  14. package/dist/core/emitterShapeWireframe.d.ts +1 -0
  15. package/dist/core/emitterShapeWireframe.js +1 -0
  16. package/dist/core/galleryPlayback.d.ts +7 -7
  17. package/dist/core/galleryPlayback.js +2 -2
  18. package/dist/core/galleryScene.d.ts +8 -13
  19. package/dist/core/galleryScene.js +14 -42
  20. package/dist/core/gallerySelectionMarker.d.ts +1 -0
  21. package/dist/core/gallerySelectionMarker.js +1 -0
  22. package/dist/core/history.d.ts +12 -1
  23. package/dist/core/history.js +16 -3
  24. package/dist/core/indexedDbProjectStore.d.ts +35 -0
  25. package/dist/core/indexedDbProjectStore.js +299 -0
  26. package/dist/core/lineShaders.d.ts +2 -0
  27. package/dist/core/lineShaders.js +7 -0
  28. package/dist/core/pickAssetFiles.d.ts +6 -0
  29. package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
  30. package/dist/core/projectStore.d.ts +61 -0
  31. package/dist/core/projectStore.js +14 -0
  32. package/dist/core/sceneEntries.d.ts +35 -0
  33. package/dist/core/sceneEntries.js +62 -0
  34. package/dist/core/transport.d.ts +12 -0
  35. package/dist/core/transport.js +36 -0
  36. package/dist/core/viewportPicker.d.ts +4 -4
  37. package/dist/core/viewportPicker.js +3 -3
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.js +2 -0
  40. package/dist/react/AssetsPanel.d.ts +22 -0
  41. package/dist/react/AssetsPanel.js +252 -0
  42. package/dist/react/ChoiceDialog.d.ts +18 -0
  43. package/dist/react/ChoiceDialog.js +48 -0
  44. package/dist/react/ContextMenu.d.ts +16 -0
  45. package/dist/react/ContextMenu.js +70 -0
  46. package/dist/react/EditorWorkspace.d.ts +21 -0
  47. package/dist/react/EditorWorkspace.js +63 -0
  48. package/dist/react/EffectEditor.d.ts +1 -1
  49. package/dist/react/EffectEditor.js +7 -4
  50. package/dist/react/EffectEditorHost.d.ts +19 -2
  51. package/dist/react/EffectEditorHost.js +1385 -607
  52. package/dist/react/EffectTreeView.d.ts +17 -0
  53. package/dist/react/EffectTreeView.js +52 -0
  54. package/dist/react/ObjectModule.d.ts +4 -0
  55. package/dist/react/ObjectModule.js +7 -10
  56. package/dist/react/ProjectsPage.d.ts +9 -0
  57. package/dist/react/ProjectsPage.js +140 -0
  58. package/dist/react/ScenePanel.d.ts +26 -0
  59. package/dist/react/ScenePanel.js +134 -0
  60. package/dist/react/TimelinePanel.d.ts +8 -1
  61. package/dist/react/TimelinePanel.js +20 -41
  62. package/dist/react/TransportBar.d.ts +24 -0
  63. package/dist/react/TransportBar.js +43 -0
  64. package/dist/react/behaviorModules.js +5 -7
  65. package/dist/react/index.d.ts +16 -2
  66. package/dist/react/index.js +8 -1
  67. package/dist/react/inspectorHints.js +1 -2
  68. package/package.json +1 -1
  69. package/src/core/assetDragSession.ts +12 -0
  70. package/src/core/assetTree.ts +148 -0
  71. package/src/core/assets.ts +262 -0
  72. package/src/core/binding.ts +8 -0
  73. package/src/core/collision.ts +4 -5
  74. package/src/core/defaultEffect.ts +9 -22
  75. package/src/core/emitterShapeWireframe.ts +1 -0
  76. package/src/core/galleryPlayback.ts +9 -9
  77. package/src/core/galleryScene.ts +15 -59
  78. package/src/core/gallerySelectionMarker.ts +1 -0
  79. package/src/core/history.ts +24 -3
  80. package/src/core/indexedDbProjectStore.ts +357 -0
  81. package/src/core/lineShaders.ts +7 -0
  82. package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +13 -8
  83. package/src/core/projectStore.ts +81 -0
  84. package/src/core/sceneEntries.ts +96 -0
  85. package/src/core/transport.ts +53 -0
  86. package/src/core/viewportPicker.ts +5 -5
  87. package/src/index.ts +11 -0
  88. package/src/react/AssetsPanel.tsx +559 -0
  89. package/src/react/ChoiceDialog.tsx +93 -0
  90. package/src/react/ContextMenu.tsx +107 -0
  91. package/src/react/EditorWorkspace.tsx +95 -0
  92. package/src/react/EffectEditor.tsx +15 -4
  93. package/src/react/EffectEditorHost.tsx +1922 -813
  94. package/src/react/EffectTreeView.tsx +117 -0
  95. package/src/react/ObjectModule.tsx +17 -13
  96. package/src/react/ProjectsPage.tsx +306 -0
  97. package/src/react/ScenePanel.tsx +318 -0
  98. package/src/react/TimelinePanel.tsx +102 -103
  99. package/src/react/TransportBar.tsx +114 -0
  100. package/src/react/behaviorModules.tsx +7 -15
  101. package/src/react/index.ts +16 -2
  102. package/src/react/inspectorHints.ts +1 -2
  103. package/dist/core/galleryDragSession.d.ts +0 -4
  104. package/dist/core/galleryDragSession.js +0 -8
  105. package/dist/core/galleryTree.d.ts +0 -49
  106. package/dist/core/galleryTree.js +0 -141
  107. package/dist/core/loadEffectGallery.d.ts +0 -36
  108. package/dist/core/loadEffectGallery.js +0 -97
  109. package/dist/core/pickGalleryFiles.d.ts +0 -5
  110. package/dist/react/GalleryPanel.d.ts +0 -20
  111. package/dist/react/GalleryPanel.js +0 -253
  112. package/src/core/galleryDragSession.ts +0 -12
  113. package/src/core/galleryTree.ts +0 -190
  114. package/src/core/loadEffectGallery.ts +0 -150
  115. package/src/react/GalleryPanel.tsx +0 -561
@@ -0,0 +1,17 @@
1
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import React from 'react';
3
+ export interface EffectTreeViewProps {
4
+ root: TransformNode;
5
+ /** Node shown in the inspector (and moved by the gizmo). */
6
+ selectedNode: TransformNode | null;
7
+ onSelect: (node: TransformNode) => void;
8
+ /** Show the root itself as the first row (default: only its descendants). */
9
+ includeRoot?: boolean;
10
+ /** Left indent of the first level, in px. */
11
+ indent?: number;
12
+ }
13
+ /**
14
+ * The effect's group/emitter hierarchy — the same tree as the timeline's label column — so a
15
+ * sub-emitter can be picked for the inspector without opening the timeline.
16
+ */
17
+ export declare function EffectTreeView(props: EffectTreeViewProps): React.ReactElement | null;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ChevronDownIcon, ChevronRightIcon, FolderIcon, SparklesIcon } from '@heroicons/react/24/solid';
3
+ import { useState } from 'react';
4
+ import { buildEffectTree } from '../core/effectTree';
5
+ import { iconStyle } from './icons';
6
+ import { theme } from './theme';
7
+ const ROW_HEIGHT = 26;
8
+ function TreeRow(props) {
9
+ const { item, level, indent, selectedNode, collapsed, onToggle, onSelect } = props;
10
+ const open = !collapsed.has(item.node);
11
+ const selected = item.node === selectedNode;
12
+ const hasChildren = item.children.length > 0;
13
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "qe-hover-bg", title: item.system ? `${item.name} — particle system` : `${item.name} — group`, onClick: () => onSelect(item.node), style: {
14
+ display: 'flex',
15
+ alignItems: 'center',
16
+ gap: 5,
17
+ height: ROW_HEIGHT,
18
+ padding: `0 8px 0 ${indent + level * 14}px`,
19
+ fontSize: 12,
20
+ cursor: 'pointer',
21
+ color: selected ? theme.text : theme.textDim,
22
+ background: selected ? 'rgba(120, 165, 255, 0.2)' : undefined,
23
+ }, children: [_jsx("span", { style: { width: 12, display: 'inline-flex', visibility: hasChildren ? 'visible' : 'hidden' }, onClick: (e) => {
24
+ e.stopPropagation();
25
+ onToggle(item.node);
26
+ }, children: open ? _jsx(ChevronDownIcon, { style: iconStyle(11) }) : _jsx(ChevronRightIcon, { style: iconStyle(11) }) }), _jsx("span", { style: { display: 'inline-flex', color: item.system ? theme.accent : theme.textDim }, children: item.system ? _jsx(SparklesIcon, { style: iconStyle(12) }) : _jsx(FolderIcon, { style: iconStyle(12) }) }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: item.name }), item.system?.looping && (_jsx("span", { title: "Looping", style: { flexShrink: 0, fontSize: 10.5 }, children: "\u27F3" }))] }), open &&
27
+ item.children.map((child) => (_jsx(TreeRow, { ...props, item: child, level: level + 1 }, child.node.uniqueId)))] }));
28
+ }
29
+ /**
30
+ * The effect's group/emitter hierarchy — the same tree as the timeline's label column — so a
31
+ * sub-emitter can be picked for the inspector without opening the timeline.
32
+ */
33
+ export function EffectTreeView(props) {
34
+ const { root, selectedNode, onSelect, includeRoot = false, indent = 8 } = props;
35
+ const [collapsed, setCollapsed] = useState(() => new Set());
36
+ if (root.isDisposed()) {
37
+ return null;
38
+ }
39
+ const tree = buildEffectTree(root);
40
+ const items = includeRoot ? [tree] : tree.children;
41
+ const toggle = (node) => setCollapsed((prev) => {
42
+ const next = new Set(prev);
43
+ if (next.has(node)) {
44
+ next.delete(node);
45
+ }
46
+ else {
47
+ next.add(node);
48
+ }
49
+ return next;
50
+ });
51
+ return (_jsx("div", { children: items.map((item) => (_jsx(TreeRow, { item: item, level: 0, indent: indent, selectedNode: selectedNode, collapsed: collapsed, onToggle: toggle, onSelect: onSelect }, item.node.uniqueId))) }));
52
+ }
@@ -2,4 +2,8 @@ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
2
  export declare function ObjectModule(props: {
3
3
  node: TransformNode;
4
4
  label: string;
5
+ /** Called before a field mutates the node — lets the host capture an undo snapshot. */
6
+ onBeforeChange?: () => void;
7
+ /** Called after a field mutated the node. */
8
+ onChanged?: () => void;
5
9
  }): import("react").JSX.Element;
@@ -27,14 +27,11 @@ export function ObjectModule(props) {
27
27
  useSyncExternalStore(subscribeTransform, getTransformRevision);
28
28
  const { node } = props;
29
29
  const euler = getEulerDegrees(node);
30
- 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) => {
31
- node.position[axis] = v;
32
- notifyTransformChanged();
33
- } }), _jsx(AxisRow, { label: "Rotation", values: euler, step: 1, onChange: (axis, v) => {
34
- setEulerDegreeAxis(node, axis, v);
35
- notifyTransformChanged();
36
- } }), _jsx(AxisRow, { label: "Scale", values: { x: node.scaling.x, y: node.scaling.y, z: node.scaling.z }, step: 0.1, onChange: (axis, v) => {
37
- node.scaling[axis] = v;
38
- notifyTransformChanged();
39
- } })] }));
30
+ const change = (mutate) => {
31
+ props.onBeforeChange?.();
32
+ mutate();
33
+ notifyTransformChanged();
34
+ props.onChanged?.();
35
+ };
36
+ 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) => change(() => (node.position[axis] = v)) }), _jsx(AxisRow, { label: "Rotation", values: euler, step: 1, onChange: (axis, v) => change(() => 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) => change(() => (node.scaling[axis] = v)) })] }));
40
37
  }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { type ProjectMeta, type ProjectStore } from '../core/projectStore';
3
+ export interface ProjectsPageProps {
4
+ store: ProjectStore;
5
+ onOpen: (project: ProjectMeta) => void;
6
+ title?: string;
7
+ }
8
+ /** Start page: the projects saved in the store, plus storage usage. */
9
+ export declare function ProjectsPage(props: ProjectsPageProps): React.ReactElement;
@@ -0,0 +1,140 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { FolderIcon, PlusIcon } from '@heroicons/react/24/solid';
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { formatBytes } from '../core/projectStore';
5
+ import { ChoiceDialog } from './ChoiceDialog';
6
+ import { ContextMenu } from './ContextMenu';
7
+ import { iconStyle } from './icons';
8
+ import { PromptDialog } from './PromptDialog';
9
+ import { buttonStyle, globalEditorStyles, theme } from './theme';
10
+ function formatDate(time) {
11
+ return new Date(time).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
12
+ }
13
+ function StorageBar(props) {
14
+ const { estimate, onPersist } = props;
15
+ const ratio = estimate?.usage != null && estimate.quota ? Math.min(1, estimate.usage / estimate.quota) : 0;
16
+ return (_jsxs("div", { style: {
17
+ border: `1px solid ${theme.border}`,
18
+ borderRadius: 10,
19
+ background: theme.sectionBg,
20
+ padding: '12px 14px',
21
+ display: 'flex',
22
+ flexDirection: 'column',
23
+ gap: 8,
24
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: 12.5 }, children: [_jsx("span", { style: { fontWeight: 600 }, children: "Browser storage" }), _jsx("span", { style: { color: theme.textDim }, children: estimate
25
+ ? `${formatBytes(estimate.usage)} used of ~${formatBytes(estimate.quota)} available`
26
+ : 'Checking…' }), _jsx("span", { style: { flex: 1 } }), estimate?.persisted ? (_jsx("span", { style: { color: '#8fe3a8', fontSize: 12 }, children: "\u2713 Persistent \u2014 the browser will not clear it" })) : (_jsx("button", { className: "qe-hover", style: { ...buttonStyle, padding: '4px 10px' }, title: "Ask the browser not to clear project data when the disk runs low", onClick: onPersist, children: "Make storage persistent" }))] }), _jsx("div", { style: { height: 6, borderRadius: 3, background: 'rgba(255, 255, 255, 0.08)', overflow: 'hidden' }, children: _jsx("div", { style: {
27
+ width: `${Math.max(ratio * 100, ratio > 0 ? 1 : 0)}%`,
28
+ height: '100%',
29
+ background: theme.accent,
30
+ } }) }), _jsx("div", { style: { fontSize: 11.5, color: theme.textDim, lineHeight: 1.5 }, children: "Projects are saved in this browser only (IndexedDB). Clearing site data removes them, and Safari deletes data of sites not opened for 7 days \u2014 export projects you care about." })] }));
31
+ }
32
+ /** Start page: the projects saved in the store, plus storage usage. */
33
+ export function ProjectsPage(props) {
34
+ const { store, onOpen, title = 'Effect editor' } = props;
35
+ const [projects, setProjects] = useState(null);
36
+ const [estimate, setEstimate] = useState(null);
37
+ const [dialog, setDialog] = useState(null);
38
+ const [menu, setMenu] = useState(null);
39
+ const [error, setError] = useState(null);
40
+ const reload = useCallback(async () => {
41
+ try {
42
+ const [list, storage] = await Promise.all([store.listProjects(), store.estimate()]);
43
+ setProjects(list);
44
+ setEstimate(storage);
45
+ }
46
+ catch (err) {
47
+ setError(err instanceof Error ? err.message : String(err));
48
+ setProjects([]);
49
+ }
50
+ }, [store]);
51
+ useEffect(() => {
52
+ void reload();
53
+ }, [reload]);
54
+ const run = (action) => {
55
+ setDialog(null);
56
+ action()
57
+ .then(reload)
58
+ .catch((err) => setError(err instanceof Error ? err.message : String(err)));
59
+ };
60
+ const projectMenu = (e, project) => {
61
+ e.preventDefault();
62
+ setMenu({
63
+ x: e.clientX,
64
+ y: e.clientY,
65
+ items: [
66
+ { label: 'Open', onSelect: () => onOpen(project) },
67
+ { label: 'Rename…', onSelect: () => setDialog({ kind: 'rename', project }) },
68
+ { label: 'Duplicate…', onSelect: () => setDialog({ kind: 'duplicate', project }) },
69
+ { label: 'Delete…', danger: true, onSelect: () => setDialog({ kind: 'delete', project }) },
70
+ ],
71
+ });
72
+ };
73
+ return (_jsxs("div", { className: "qe-root", style: {
74
+ width: '100%',
75
+ height: '100%',
76
+ overflowY: 'auto',
77
+ background: '#070b16',
78
+ color: theme.text,
79
+ fontFamily: theme.font,
80
+ }, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { style: {
81
+ maxWidth: 960,
82
+ margin: '0 auto',
83
+ padding: '32px 24px',
84
+ display: 'flex',
85
+ flexDirection: 'column',
86
+ gap: 18,
87
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 12 }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 22, fontWeight: 700 }, children: title }), _jsx("div", { style: { fontSize: 13, color: theme.textDim, marginTop: 4 }, children: "Projects keep their assets and scene between sessions." })] }), _jsx("span", { style: { flex: 1 } }), _jsxs("button", { className: "qe-hover", style: {
88
+ ...buttonStyle,
89
+ display: 'inline-flex',
90
+ alignItems: 'center',
91
+ gap: 6,
92
+ padding: '8px 14px',
93
+ }, onClick: () => setDialog({ kind: 'create' }), children: [_jsx(PlusIcon, { style: iconStyle(14) }), " New project"] })] }), _jsx(StorageBar, { estimate: estimate, onPersist: () => {
94
+ void store.requestPersistence().then((granted) => {
95
+ if (!granted) {
96
+ setError('The browser did not grant persistent storage. Chrome grants it more readily to sites you visit often or have bookmarked.');
97
+ }
98
+ return reload();
99
+ });
100
+ } }), projects === null ? (_jsx("div", { style: { color: theme.textDim, fontSize: 13 }, children: "Loading projects\u2026" })) : projects.length === 0 ? (_jsx("div", { style: {
101
+ border: `1px dashed ${theme.border}`,
102
+ borderRadius: 10,
103
+ padding: '36px 20px',
104
+ textAlign: 'center',
105
+ color: theme.textDim,
106
+ fontSize: 13,
107
+ }, children: "No projects yet. Create one to start building effects." })) : (_jsx("div", { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }, children: projects.map((project) => (_jsxs("button", { className: "qe-hover", title: "Open \u00B7 right-click for more", onClick: () => onOpen(project), onContextMenu: (e) => projectMenu(e, project), style: {
108
+ display: 'flex',
109
+ flexDirection: 'column',
110
+ alignItems: 'flex-start',
111
+ gap: 6,
112
+ textAlign: 'left',
113
+ padding: 14,
114
+ borderRadius: 10,
115
+ border: `1px solid ${theme.border}`,
116
+ background: theme.panelBg,
117
+ color: theme.text,
118
+ cursor: 'pointer',
119
+ fontFamily: theme.font,
120
+ }, children: [_jsxs("span", { style: {
121
+ display: 'flex',
122
+ alignItems: 'center',
123
+ gap: 8,
124
+ fontSize: 14,
125
+ fontWeight: 600,
126
+ }, children: [_jsx(FolderIcon, { style: { ...iconStyle(16), color: theme.accent } }), project.name] }), _jsxs("span", { style: { fontSize: 12, color: theme.textDim }, children: [project.assetCount, " asset(s) \u00B7 ", formatBytes(project.sizeBytes)] }), _jsxs("span", { style: { fontSize: 11.5, color: theme.textDim }, children: ["Edited ", formatDate(project.updatedAt)] })] }, project.id))) }))] }), menu && _jsx(ContextMenu, { x: menu.x, y: menu.y, items: menu.items, onClose: () => setMenu(null) }), dialog?.kind === 'create' && (_jsx(PromptDialog, { title: "New project", defaultValue: "My effects", confirmLabel: "Create", onSubmit: (name) => {
127
+ setDialog(null);
128
+ store
129
+ .createProject(name)
130
+ .then(onOpen)
131
+ .catch((err) => setError(err instanceof Error ? err.message : String(err)));
132
+ }, onCancel: () => setDialog(null) })), dialog?.kind === 'rename' && (_jsx(PromptDialog, { title: "Rename project", defaultValue: dialog.project.name, confirmLabel: "Rename", onSubmit: (name) => run(() => store.renameProject(dialog.project.id, name)), onCancel: () => setDialog(null) })), dialog?.kind === 'duplicate' && (_jsx(PromptDialog, { title: "Duplicate project", defaultValue: `${dialog.project.name} copy`, confirmLabel: "Duplicate", onSubmit: (name) => run(() => store.duplicateProject(dialog.project.id, name)), onCancel: () => setDialog(null) })), dialog?.kind === 'delete' && (_jsx(ChoiceDialog, { title: `Delete “${dialog.project.name}”?`, message: `Its ${dialog.project.assetCount} asset(s) and saved scene are removed from this browser. This cannot be undone.`, actions: [
133
+ { label: 'Cancel', onSelect: () => setDialog(null) },
134
+ {
135
+ label: 'Delete',
136
+ danger: true,
137
+ onSelect: () => run(() => store.deleteProject(dialog.project.id)),
138
+ },
139
+ ], onCancel: () => setDialog(null) })), error && (_jsx(ChoiceDialog, { title: "Projects", message: error, actions: [{ label: 'OK', primary: true, onSelect: () => setError(null) }], onCancel: () => setError(null) }))] }));
140
+ }
@@ -0,0 +1,26 @@
1
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import React from 'react';
3
+ import type { SceneEntry } from '../core/sceneEntries';
4
+ export interface ScenePanelProps {
5
+ entries: SceneEntry[];
6
+ /** Effect shown in the inspector and timeline. */
7
+ focusedRoot: TransformNode | null;
8
+ /** Visible entries — they render and play with the stage transport. */
9
+ visibleRootIds: ReadonlySet<number>;
10
+ onFocus: (entry: SceneEntry) => void;
11
+ onToggleVisible: (entry: SceneEntry) => void;
12
+ /** Root of the entry shown alone, or null. */
13
+ soloRootId: number | null;
14
+ onToggleSolo: (entry: SceneEntry) => void;
15
+ onDelete: (entry: SceneEntry) => void;
16
+ /** "Save as new asset…" in the row's right-click menu. */
17
+ onSaveAsAsset?: (entry: SceneEntry) => void;
18
+ onCreateDefault: () => void;
19
+ /** Node shown in the inspector; highlighted in the expanded effect trees. */
20
+ selectedNode?: TransformNode | null;
21
+ /** Picks a group/emitter inside an effect (focusing the effect first when needed). */
22
+ onSelectNode?: (entry: SceneEntry, node: TransformNode) => void;
23
+ }
24
+ export declare const rowIconButtonStyle: React.CSSProperties;
25
+ /** Lists the effect instances that are in the scene right now. */
26
+ export declare function ScenePanel(props: ScenePanelProps): React.ReactElement;
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeSlashIcon, SparklesIcon, TrashIcon, ViewfinderCircleIcon, } from '@heroicons/react/24/solid';
3
+ import React, { useEffect, useState } from 'react';
4
+ import { buildEffectTree } from '../core/effectTree';
5
+ import { ContextMenu } from './ContextMenu';
6
+ import { EffectTreeView } from './EffectTreeView';
7
+ import { iconStyle } from './icons';
8
+ import { buttonStyle, theme } from './theme';
9
+ const ROW_HEIGHT = 34;
10
+ export const rowIconButtonStyle = {
11
+ flexShrink: 0,
12
+ width: 28,
13
+ border: 'none',
14
+ background: 'transparent',
15
+ color: theme.textDim,
16
+ cursor: 'pointer',
17
+ display: 'inline-flex',
18
+ alignItems: 'center',
19
+ justifyContent: 'center',
20
+ padding: 0,
21
+ };
22
+ function SceneRow(props) {
23
+ const { entry, focused, visible, solo, onFocus, onToggleVisible, onToggleSolo, onDelete, onContextMenu, hasTree, expanded, onToggleExpanded, } = props;
24
+ const isLooping = entry.systems.some((system) => system.looping);
25
+ return (_jsxs("div", { onContextMenu: (e) => onContextMenu(e, entry), style: {
26
+ display: 'flex',
27
+ alignItems: 'stretch',
28
+ height: ROW_HEIGHT,
29
+ borderBottom: `1px solid ${theme.border}`,
30
+ background: focused ? 'rgba(120, 165, 255, 0.22)' : 'transparent',
31
+ boxSizing: 'border-box',
32
+ }, children: [_jsx("button", { type: "button", className: "qe-hover", title: visible ? 'Hide — stops and clears this effect' : 'Show — plays with the stage transport', style: {
33
+ ...rowIconButtonStyle,
34
+ borderRight: `1px solid ${theme.border}`,
35
+ color: visible ? theme.accent : theme.textDim,
36
+ opacity: visible ? 1 : 0.6,
37
+ }, onClick: (e) => {
38
+ e.stopPropagation();
39
+ onToggleVisible(entry);
40
+ }, children: visible ? _jsx(EyeIcon, { style: iconStyle(14) }) : _jsx(EyeSlashIcon, { style: iconStyle(14) }) }), _jsxs("button", { type: "button", className: "qe-hover-bg", title: `${entry.name}${entry.assetId != null ? ` — from ${entry.path}` : ''} · Ctrl+click toggles visibility`, style: {
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ gap: 8,
44
+ flex: 1,
45
+ minWidth: 0,
46
+ textAlign: 'left',
47
+ border: 'none',
48
+ background: 'transparent',
49
+ color: focused ? theme.text : visible ? theme.textDim : 'rgba(183, 198, 234, 0.5)',
50
+ padding: '0 8px 0 4px',
51
+ fontSize: 12.5,
52
+ cursor: 'pointer',
53
+ fontFamily: theme.font,
54
+ }, onClick: (e) => {
55
+ if (e.ctrlKey || e.metaKey) {
56
+ onToggleVisible(entry);
57
+ return;
58
+ }
59
+ onFocus(entry);
60
+ }, children: [_jsx("span", { title: expanded ? 'Collapse' : 'Show its groups and emitters', style: {
61
+ width: 12,
62
+ flexShrink: 0,
63
+ display: 'inline-flex',
64
+ visibility: hasTree ? 'visible' : 'hidden',
65
+ }, onClick: (e) => {
66
+ e.stopPropagation();
67
+ onToggleExpanded(entry);
68
+ }, children: expanded ? _jsx(ChevronDownIcon, { style: iconStyle(11) }) : _jsx(ChevronRightIcon, { style: iconStyle(11) }) }), _jsx("span", { style: { width: 14, flexShrink: 0, display: 'inline-flex', opacity: visible ? 1 : 0.4 }, children: _jsx(SparklesIcon, { style: iconStyle(14) }) }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: entry.name }), isLooping && (_jsx("span", { title: "Looping effect", style: { flexShrink: 0, fontSize: 11, color: theme.textDim }, children: "\u27F3" }))] }), _jsx("button", { type: "button", className: "qe-hover", title: solo ? 'Exit solo — show the other effects again' : 'Solo — hide every other effect', style: {
69
+ ...rowIconButtonStyle,
70
+ borderLeft: `1px solid ${theme.border}`,
71
+ color: solo ? '#ffcc66' : theme.textDim,
72
+ }, onClick: (e) => {
73
+ e.stopPropagation();
74
+ onToggleSolo(entry);
75
+ }, children: _jsx(ViewfinderCircleIcon, { style: iconStyle(14) }) }), _jsx("button", { type: "button", className: "qe-hover", title: "Delete from scene (Delete)", style: { ...rowIconButtonStyle, borderLeft: `1px solid ${theme.border}` }, onClick: (e) => {
76
+ e.stopPropagation();
77
+ onDelete(entry);
78
+ }, children: _jsx(TrashIcon, { style: iconStyle(13) }) })] }));
79
+ }
80
+ /** Lists the effect instances that are in the scene right now. */
81
+ export function ScenePanel(props) {
82
+ const { entries, focusedRoot, visibleRootIds, soloRootId, onFocus, onToggleVisible, onToggleSolo, onDelete, onSaveAsAsset, onCreateDefault, selectedNode = null, onSelectNode, } = props;
83
+ const [menu, setMenu] = useState(null);
84
+ const [expanded, setExpanded] = useState(() => new Set());
85
+ // The focused effect opens its tree, so its sub-emitters are one click away.
86
+ useEffect(() => {
87
+ if (focusedRoot) {
88
+ setExpanded((prev) => (prev.has(focusedRoot.uniqueId) ? prev : new Set(prev).add(focusedRoot.uniqueId)));
89
+ }
90
+ }, [focusedRoot]);
91
+ const toggleExpanded = (entry) => setExpanded((prev) => {
92
+ const next = new Set(prev);
93
+ if (next.has(entry.root.uniqueId)) {
94
+ next.delete(entry.root.uniqueId);
95
+ }
96
+ else {
97
+ next.add(entry.root.uniqueId);
98
+ }
99
+ return next;
100
+ });
101
+ const openMenu = (e, entry) => {
102
+ e.preventDefault();
103
+ const visible = visibleRootIds.has(entry.root.uniqueId);
104
+ const items = [];
105
+ if (onSaveAsAsset) {
106
+ items.push({ label: 'Save as new asset…', onSelect: () => onSaveAsAsset(entry) });
107
+ }
108
+ items.push({ label: visible ? 'Hide' : 'Show', onSelect: () => onToggleVisible(entry) }, {
109
+ label: soloRootId === entry.root.uniqueId ? 'Exit solo' : 'Solo',
110
+ onSelect: () => onToggleSolo(entry),
111
+ }, { label: 'Delete', danger: true, onSelect: () => onDelete(entry) });
112
+ setMenu({ x: e.clientX, y: e.clientY, items });
113
+ };
114
+ return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 10, height: '100%', minHeight: 0 }, children: [_jsx("div", { style: { display: 'flex', gap: 6 }, children: _jsx("button", { type: "button", className: "qe-hover", style: buttonStyle, onClick: onCreateDefault, children: "New effect" }) }), entries.length === 0 ? (_jsxs("div", { style: {
115
+ padding: '14px 12px',
116
+ fontSize: 12,
117
+ color: theme.textDim,
118
+ lineHeight: 1.5,
119
+ border: `1px solid ${theme.border}`,
120
+ borderRadius: 8,
121
+ background: theme.sectionBg,
122
+ }, children: ["The scene is empty. Click ", _jsx("strong", { children: "New effect" }), ", or drag effects from the", ' ', _jsx("strong", { children: "Assets" }), " tab below into the viewport."] })) : (_jsx("div", { style: {
123
+ flex: 1,
124
+ minHeight: 0,
125
+ overflowY: 'auto',
126
+ border: `1px solid ${theme.border}`,
127
+ borderRadius: 8,
128
+ background: theme.sectionBg,
129
+ }, children: entries.map((entry) => {
130
+ const hasTree = onSelectNode !== undefined && buildEffectTree(entry.root).children.length > 0;
131
+ const open = hasTree && expanded.has(entry.root.uniqueId);
132
+ return (_jsxs(React.Fragment, { children: [_jsx(SceneRow, { entry: entry, hasTree: hasTree, expanded: open, onToggleExpanded: toggleExpanded, focused: focusedRoot === entry.root, visible: visibleRootIds.has(entry.root.uniqueId), solo: soloRootId === entry.root.uniqueId, onFocus: onFocus, onToggleVisible: onToggleVisible, onToggleSolo: onToggleSolo, onDelete: onDelete, onContextMenu: openMenu }), open && (_jsx("div", { style: { borderBottom: `1px solid ${theme.border}`, padding: '2px 0' }, children: _jsx(EffectTreeView, { root: entry.root, selectedNode: selectedNode, indent: 34, onSelect: (node) => onSelectNode(entry, node) }) }))] }, entry.root.uniqueId));
133
+ }) })), menu && _jsx(ContextMenu, { x: menu.x, y: menu.y, items: menu.items, onClose: () => setMenu(null) })] }));
134
+ }
@@ -18,8 +18,15 @@ export interface TimelinePanelProps {
18
18
  setStagePlaying: (playing: boolean) => void;
19
19
  speed: number;
20
20
  setSpeed: (speed: number) => void;
21
- /** False when a catalog entry is selected but not yet dropped into the viewport. */
21
+ /** False when the focused effect is hidden — it has nothing to play. */
22
22
  playbackEnabled?: boolean;
23
+ /** Fills its parent (e.g. a bottom dock) instead of drawing its own fixed-height bar. */
24
+ docked?: boolean;
25
+ /**
26
+ * Play/step/restart/speed buttons and the counter in the timeline header (default true).
27
+ * Hosts that show a TransportBar in the viewport turn this off.
28
+ */
29
+ showTransport?: boolean;
23
30
  }
24
31
  /** Small gap between the label column and the first pixel of the time axis. */
25
32
  export declare const TIMELINE_INSET = 10;
@@ -5,6 +5,7 @@ import { groupNodes } from '../core/groups';
5
5
  import { clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, } from '../core/playback';
6
6
  import { pauseFocus, playFocus, scrubFocusTo, scrubFocusToSettled } from '../core/scrub';
7
7
  import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
8
+ import { PLAYBACK_SPEEDS, restartFocusPlayback, toggleStagePlayback } from '../core/transport';
8
9
  import { TimelineTrackRow } from './TimelineTrackRow';
9
10
  import { iconStyle } from './icons';
10
11
  import { buttonStyle, theme } from './theme';
@@ -29,7 +30,7 @@ function pickTickInterval(pxPerSecond) {
29
30
  * apply only to the focused effect without stopping other previews.
30
31
  */
31
32
  export function TimelinePanel(props) {
32
- const { binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true, } = props;
33
+ const { binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true, docked = false, showTransport = true, } = props;
33
34
  const [bodyWidthPx, setBodyWidthPx] = useState(600);
34
35
  const [collapsedGroups, setCollapsedGroups] = useState(() => new Set());
35
36
  const [hiddenSystems, setHiddenSystems] = useState(() => new Set());
@@ -207,34 +208,12 @@ export function TimelinePanel(props) {
207
208
  }
208
209
  };
209
210
  const restartFocus = () => {
210
- scrubFocusTo(binding, renderer, 0);
211
- const state = playbackRef.current;
212
- setFocusElapsed(state, focusRootId, 0);
213
- clearFocusFinished(state, focusRootId);
214
- if (stagePlaying) {
215
- playFocus(binding);
216
- }
211
+ restartFocusPlayback(binding, renderer, scene, playbackRef.current);
217
212
  if (playheadRef.current) {
218
213
  playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET}px`;
219
214
  }
220
- scene.render();
221
- };
222
- const handleStagePlayPause = () => {
223
- const state = playbackRef.current;
224
- if (!stagePlaying) {
225
- if (isFocusFinished(state, focusRootId)) {
226
- binding.restart();
227
- clearFocusFinished(state, focusRootId);
228
- setFocusElapsed(state, focusRootId, 0);
229
- scene.render();
230
- }
231
- state.stagePlaying = true;
232
- setStagePlaying(true);
233
- return;
234
- }
235
- state.stagePlaying = false;
236
- setStagePlaying(false);
237
215
  };
216
+ const handleStagePlayPause = () => toggleStagePlayback(binding, scene, playbackRef.current, stagePlaying, setStagePlaying);
238
217
  const ticks = [];
239
218
  for (let t = 0; t <= timelineSpan; t += pickTickInterval(pxPerSecond)) {
240
219
  ticks.push(t);
@@ -249,31 +228,31 @@ export function TimelinePanel(props) {
249
228
  gap: 6,
250
229
  };
251
230
  return (_jsxs("div", { style: {
252
- flexShrink: 0,
253
- height: PANEL_HEIGHT,
254
- borderTop: `1px solid ${theme.border}`,
231
+ ...(docked
232
+ ? { flex: 1, minHeight: 0 }
233
+ : { flexShrink: 0, height: PANEL_HEIGHT, borderTop: `1px solid ${theme.border}` }),
255
234
  background: theme.panelBg,
256
235
  display: 'flex',
257
236
  flexDirection: 'column',
258
237
  fontFamily: theme.font,
259
238
  color: theme.text,
260
- }, children: [_jsxs("div", { style: {
239
+ }, children: [(showTransport || groupSelection.size >= 2) && (_jsxs("div", { style: {
261
240
  display: 'flex',
262
241
  alignItems: 'center',
263
242
  gap: 6,
264
243
  padding: '6px 10px',
265
244
  borderBottom: `1px solid ${theme.border}`,
266
- }, children: [_jsx("button", { className: "qe-hover", style: { ...overlayButton, opacity: playbackEnabled ? 1 : 0.45 }, title: stagePlaying ? 'Pause stage preview' : 'Play stage preview', disabled: !playbackEnabled, onClick: handleStagePlayPause, children: stagePlaying ? _jsx(PauseIcon, { style: iconStyle(14) }) : _jsx(PlayIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: { ...overlayButton, opacity: playbackEnabled ? 1 : 0.45 }, title: "Step one frame (stage)", disabled: !playbackEnabled || stagePlaying, onClick: () => (playbackRef.current.stepQueued = true), children: _jsx(ForwardIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: { ...overlayButton, opacity: playbackEnabled ? 1 : 0.45 }, title: "Restart focused effect only", disabled: !playbackEnabled, onClick: restartFocus, children: _jsx(ArrowPathIcon, { style: iconStyle(14) }) }), _jsx("select", { className: "qe-hover", style: { ...overlayButton, padding: '5px 4px', opacity: playbackEnabled ? 1 : 0.45 }, title: "Playback speed", value: speed, disabled: !playbackEnabled, onChange: (e) => {
267
- const next = Number(e.target.value);
268
- playbackRef.current.speed = next;
269
- setSpeed(next);
270
- }, children: [0.1, 0.25, 0.5, 1, 2, 4].map((s) => (_jsxs("option", { value: s, children: ["\u00D7", s] }, s))) }), _jsx("span", { ref: counterRef, style: {
271
- fontSize: 12,
272
- color: theme.textDim,
273
- fontVariantNumeric: 'tabular-nums',
274
- paddingLeft: 4,
275
- minWidth: 120,
276
- } }), groupSelection.size >= 2 && (_jsxs("button", { className: "qe-hover", style: overlayButton, title: "Group the checked rows", onClick: groupSelectedNodes, children: [_jsx(RectangleGroupIcon, { style: iconStyle(14) }), "Group (", groupSelection.size, ")"] }))] }), _jsx("div", { ref: bodyRef, style: { position: 'relative', flex: 1, minHeight: 0 }, children: !playbackEnabled ? (_jsx("div", { style: {
245
+ }, children: [showTransport && (_jsxs(_Fragment, { children: [_jsx("button", { className: "qe-hover", style: { ...overlayButton, opacity: playbackEnabled ? 1 : 0.45 }, title: stagePlaying ? 'Pause stage preview' : 'Play stage preview', disabled: !playbackEnabled, onClick: handleStagePlayPause, children: stagePlaying ? (_jsx(PauseIcon, { style: iconStyle(14) })) : (_jsx(PlayIcon, { style: iconStyle(14) })) }), _jsx("button", { className: "qe-hover", style: { ...overlayButton, opacity: playbackEnabled ? 1 : 0.45 }, title: "Step one frame (stage)", disabled: !playbackEnabled || stagePlaying, onClick: () => (playbackRef.current.stepQueued = true), children: _jsx(ForwardIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: { ...overlayButton, opacity: playbackEnabled ? 1 : 0.45 }, title: "Restart focused effect only", disabled: !playbackEnabled, onClick: restartFocus, children: _jsx(ArrowPathIcon, { style: iconStyle(14) }) }), _jsx("select", { className: "qe-hover", style: { ...overlayButton, padding: '5px 4px', opacity: playbackEnabled ? 1 : 0.45 }, title: "Playback speed", value: speed, disabled: !playbackEnabled, onChange: (e) => {
246
+ const next = Number(e.target.value);
247
+ playbackRef.current.speed = next;
248
+ setSpeed(next);
249
+ }, children: PLAYBACK_SPEEDS.map((s) => (_jsxs("option", { value: s, children: ["\u00D7", s] }, s))) }), _jsx("span", { ref: counterRef, style: {
250
+ fontSize: 12,
251
+ color: theme.textDim,
252
+ fontVariantNumeric: 'tabular-nums',
253
+ paddingLeft: 4,
254
+ minWidth: 120,
255
+ } })] })), groupSelection.size >= 2 && (_jsxs("button", { className: "qe-hover", style: overlayButton, title: "Group the checked rows", onClick: groupSelectedNodes, children: [_jsx(RectangleGroupIcon, { style: iconStyle(14) }), "Group (", groupSelection.size, ")"] }))] })), _jsx("div", { ref: bodyRef, style: { position: 'relative', flex: 1, minHeight: 0 }, children: !playbackEnabled ? (_jsx("div", { style: {
277
256
  position: 'absolute',
278
257
  inset: 0,
279
258
  display: 'flex',
@@ -284,7 +263,7 @@ export function TimelinePanel(props) {
284
263
  fontSize: 13,
285
264
  color: theme.textDim,
286
265
  lineHeight: 1.5,
287
- }, children: "Selected effect is not in the scene. Drag it into the viewport to preview playback." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { style: {
266
+ }, children: "The focused effect is hidden. Show it in the Scene list to preview playback." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { style: {
288
267
  position: 'absolute',
289
268
  top: 0,
290
269
  left: 0,
@@ -0,0 +1,24 @@
1
+ import type { Scene } from '@babylonjs/core/scene';
2
+ import type { BatchedRenderer } from 'babylon.quarks';
3
+ import React from 'react';
4
+ import type { EffectBinding } from '../core/binding';
5
+ import type { PlaybackState } from '../core/playback';
6
+ export interface TransportBarProps {
7
+ binding: EffectBinding | null;
8
+ scene: Scene;
9
+ renderer: BatchedRenderer;
10
+ playbackRef: React.MutableRefObject<PlaybackState>;
11
+ /** The host's render loop writes time and particle count here. */
12
+ counterRef: React.RefObject<HTMLSpanElement>;
13
+ stagePlaying: boolean;
14
+ setStagePlaying: (playing: boolean) => void;
15
+ speed: number;
16
+ setSpeed: (speed: number) => void;
17
+ /** False when the focused effect is hidden. */
18
+ enabled: boolean;
19
+ }
20
+ /**
21
+ * Floating playback controls in the viewport (like Unity's Particle Effect overlay), so playing
22
+ * and restarting never needs the timeline open.
23
+ */
24
+ export declare function TransportBar(props: TransportBarProps): React.ReactElement;
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ArrowPathIcon, ForwardIcon, PauseIcon, PlayIcon } from '@heroicons/react/24/solid';
3
+ import { PLAYBACK_SPEEDS, restartFocusPlayback, toggleStagePlayback } from '../core/transport';
4
+ import { iconStyle } from './icons';
5
+ import { buttonStyle, theme } from './theme';
6
+ const barButton = {
7
+ ...buttonStyle,
8
+ width: 28,
9
+ height: 26,
10
+ padding: 0,
11
+ display: 'inline-flex',
12
+ alignItems: 'center',
13
+ justifyContent: 'center',
14
+ };
15
+ /**
16
+ * Floating playback controls in the viewport (like Unity's Particle Effect overlay), so playing
17
+ * and restarting never needs the timeline open.
18
+ */
19
+ export function TransportBar(props) {
20
+ const { binding, scene, renderer, playbackRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed } = props;
21
+ const enabled = props.enabled && binding !== null;
22
+ return (_jsxs("div", { style: {
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ gap: 4,
26
+ padding: 4,
27
+ background: theme.panelBg,
28
+ border: `1px solid ${theme.border}`,
29
+ borderRadius: 8,
30
+ fontFamily: theme.font,
31
+ }, children: [_jsx("button", { className: "qe-hover", style: barButton, title: stagePlaying ? 'Pause (Space)' : 'Play (Space)', disabled: !enabled, onClick: () => toggleStagePlayback(binding, scene, playbackRef.current, stagePlaying, setStagePlaying), children: stagePlaying ? _jsx(PauseIcon, { style: iconStyle(14) }) : _jsx(PlayIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: barButton, title: "Step one frame", disabled: !enabled || stagePlaying, onClick: () => (playbackRef.current.stepQueued = true), children: _jsx(ForwardIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: barButton, title: "Restart the focused effect", disabled: !enabled, onClick: () => binding && restartFocusPlayback(binding, renderer, scene, playbackRef.current), children: _jsx(ArrowPathIcon, { style: iconStyle(14) }) }), _jsx("select", { className: "qe-hover", style: { ...barButton, width: 'auto', padding: '0 4px' }, title: "Playback speed", value: speed, disabled: !enabled, onChange: (e) => {
32
+ const next = Number(e.target.value);
33
+ playbackRef.current.speed = next;
34
+ setSpeed(next);
35
+ }, children: PLAYBACK_SPEEDS.map((s) => (_jsxs("option", { value: s, children: ["\u00D7", s] }, s))) }), _jsx("span", { ref: counterRef, style: {
36
+ fontSize: 12,
37
+ color: theme.textDim,
38
+ fontVariantNumeric: 'tabular-nums',
39
+ padding: '0 6px',
40
+ minWidth: 150,
41
+ whiteSpace: 'nowrap',
42
+ } })] }));
43
+ }