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.
- package/CHANGELOG.md +28 -0
- package/dist/core/assetDragSession.d.ts +4 -0
- package/dist/core/assetDragSession.js +8 -0
- package/dist/core/assetTree.d.ts +39 -0
- package/dist/core/assetTree.js +112 -0
- package/dist/core/assets.d.ts +56 -0
- package/dist/core/assets.js +207 -0
- package/dist/core/binding.d.ts +5 -0
- package/dist/core/binding.js +7 -0
- package/dist/core/collision.d.ts +4 -5
- package/dist/core/collision.js +4 -5
- package/dist/core/defaultEffect.d.ts +4 -5
- package/dist/core/defaultEffect.js +5 -14
- package/dist/core/emitterShapeWireframe.d.ts +1 -0
- package/dist/core/emitterShapeWireframe.js +1 -0
- package/dist/core/galleryPlayback.d.ts +7 -7
- package/dist/core/galleryPlayback.js +2 -2
- package/dist/core/galleryScene.d.ts +8 -13
- package/dist/core/galleryScene.js +14 -42
- package/dist/core/gallerySelectionMarker.d.ts +1 -0
- package/dist/core/gallerySelectionMarker.js +1 -0
- package/dist/core/history.d.ts +12 -1
- package/dist/core/history.js +16 -3
- package/dist/core/indexedDbProjectStore.d.ts +35 -0
- package/dist/core/indexedDbProjectStore.js +299 -0
- package/dist/core/lineShaders.d.ts +2 -0
- package/dist/core/lineShaders.js +7 -0
- package/dist/core/pickAssetFiles.d.ts +6 -0
- package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
- package/dist/core/projectStore.d.ts +61 -0
- package/dist/core/projectStore.js +14 -0
- package/dist/core/sceneEntries.d.ts +35 -0
- package/dist/core/sceneEntries.js +62 -0
- package/dist/core/transport.d.ts +12 -0
- package/dist/core/transport.js +36 -0
- package/dist/core/viewportPicker.d.ts +4 -4
- package/dist/core/viewportPicker.js +3 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/react/AssetsPanel.d.ts +22 -0
- package/dist/react/AssetsPanel.js +252 -0
- package/dist/react/ChoiceDialog.d.ts +18 -0
- package/dist/react/ChoiceDialog.js +48 -0
- package/dist/react/ContextMenu.d.ts +16 -0
- package/dist/react/ContextMenu.js +70 -0
- package/dist/react/EditorWorkspace.d.ts +21 -0
- package/dist/react/EditorWorkspace.js +63 -0
- package/dist/react/EffectEditor.d.ts +1 -1
- package/dist/react/EffectEditor.js +7 -4
- package/dist/react/EffectEditorHost.d.ts +19 -2
- package/dist/react/EffectEditorHost.js +1385 -607
- package/dist/react/EffectTreeView.d.ts +17 -0
- package/dist/react/EffectTreeView.js +52 -0
- package/dist/react/ObjectModule.d.ts +4 -0
- package/dist/react/ObjectModule.js +7 -10
- package/dist/react/ProjectsPage.d.ts +9 -0
- package/dist/react/ProjectsPage.js +140 -0
- package/dist/react/ScenePanel.d.ts +26 -0
- package/dist/react/ScenePanel.js +134 -0
- package/dist/react/TimelinePanel.d.ts +8 -1
- package/dist/react/TimelinePanel.js +20 -41
- package/dist/react/TransportBar.d.ts +24 -0
- package/dist/react/TransportBar.js +43 -0
- package/dist/react/behaviorModules.js +5 -7
- package/dist/react/index.d.ts +16 -2
- package/dist/react/index.js +8 -1
- package/dist/react/inspectorHints.js +1 -2
- package/package.json +1 -1
- package/src/core/assetDragSession.ts +12 -0
- package/src/core/assetTree.ts +148 -0
- package/src/core/assets.ts +262 -0
- package/src/core/binding.ts +8 -0
- package/src/core/collision.ts +4 -5
- package/src/core/defaultEffect.ts +9 -22
- package/src/core/emitterShapeWireframe.ts +1 -0
- package/src/core/galleryPlayback.ts +9 -9
- package/src/core/galleryScene.ts +15 -59
- package/src/core/gallerySelectionMarker.ts +1 -0
- package/src/core/history.ts +24 -3
- package/src/core/indexedDbProjectStore.ts +357 -0
- package/src/core/lineShaders.ts +7 -0
- package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +13 -8
- package/src/core/projectStore.ts +81 -0
- package/src/core/sceneEntries.ts +96 -0
- package/src/core/transport.ts +53 -0
- package/src/core/viewportPicker.ts +5 -5
- package/src/index.ts +11 -0
- package/src/react/AssetsPanel.tsx +559 -0
- package/src/react/ChoiceDialog.tsx +93 -0
- package/src/react/ContextMenu.tsx +107 -0
- package/src/react/EditorWorkspace.tsx +95 -0
- package/src/react/EffectEditor.tsx +15 -4
- package/src/react/EffectEditorHost.tsx +1922 -813
- package/src/react/EffectTreeView.tsx +117 -0
- package/src/react/ObjectModule.tsx +17 -13
- package/src/react/ProjectsPage.tsx +306 -0
- package/src/react/ScenePanel.tsx +318 -0
- package/src/react/TimelinePanel.tsx +102 -103
- package/src/react/TransportBar.tsx +114 -0
- package/src/react/behaviorModules.tsx +7 -15
- package/src/react/index.ts +16 -2
- package/src/react/inspectorHints.ts +1 -2
- package/dist/core/galleryDragSession.d.ts +0 -4
- package/dist/core/galleryDragSession.js +0 -8
- package/dist/core/galleryTree.d.ts +0 -49
- package/dist/core/galleryTree.js +0 -141
- package/dist/core/loadEffectGallery.d.ts +0 -36
- package/dist/core/loadEffectGallery.js +0 -97
- package/dist/core/pickGalleryFiles.d.ts +0 -5
- package/dist/react/GalleryPanel.d.ts +0 -20
- package/dist/react/GalleryPanel.js +0 -253
- package/src/core/galleryDragSession.ts +0 -12
- package/src/core/galleryTree.ts +0 -190
- package/src/core/loadEffectGallery.ts +0 -150
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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: "
|
|
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
|
+
}
|