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,14 @@
1
+ export function formatBytes(bytes) {
2
+ if (bytes == null)
3
+ return '—';
4
+ if (bytes < 1024)
5
+ return `${bytes} B`;
6
+ const units = ['KB', 'MB', 'GB', 'TB'];
7
+ let value = bytes / 1024;
8
+ let unit = 0;
9
+ while (value >= 1024 && unit < units.length - 1) {
10
+ value /= 1024;
11
+ unit++;
12
+ }
13
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
14
+ }
@@ -0,0 +1,35 @@
1
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import type { Scene } from '@babylonjs/core/scene';
3
+ import type { BatchedRenderer, ParticleSystem } from 'babylon.quarks';
4
+ import type { EffectAsset } from './assets';
5
+ import { EffectBinding } from './binding';
6
+ /**
7
+ * One effect instance placed in the scene. Every instance is live (its systems are registered
8
+ * with the renderer) and independent: dropping the same asset three times gives three entries
9
+ * that are edited separately.
10
+ */
11
+ export interface SceneEntry {
12
+ name: string;
13
+ /** Path of the asset it came from, or a synthetic `_created/…` path for new effects. */
14
+ path: string;
15
+ /** Asset this instance was made from, if any. */
16
+ assetId: number | null;
17
+ root: TransformNode;
18
+ systems: ParticleSystem[];
19
+ }
20
+ /** `name`, or `name (2)`, `name (3)`… when an entry already uses it. */
21
+ export declare function uniqueEntryName(name: string, existing: SceneEntry[]): string;
22
+ /**
23
+ * Parses an asset into a new scene instance with its systems registered (paused). Returns null
24
+ * when the JSON holds no particle systems.
25
+ */
26
+ export declare function instantiateSceneEntry(scene: Scene, renderer: BatchedRenderer, asset: EffectAsset, existing: SceneEntry[]): SceneEntry | null;
27
+ /** Builds an edit binding for one scene entry. */
28
+ export declare function bindingFromSceneEntry(entry: SceneEntry): EffectBinding;
29
+ /**
30
+ * Unregisters and disposes one scene entry — including systems the inspector added after the
31
+ * entry was created, which only the live emitter tree knows about.
32
+ */
33
+ export declare function disposeSceneEntry(entry: SceneEntry, renderer: BatchedRenderer): void;
34
+ /** Number of scene instances per asset id. */
35
+ export declare function countInstancesByAsset(entries: SceneEntry[]): Map<number, number>;
@@ -0,0 +1,62 @@
1
+ import { QuarksUtil } from 'babylon.quarks';
2
+ import { EffectBinding } from './binding';
3
+ import { disposeLoadedEffect, parseEffectFromJson } from './loadEffect';
4
+ /** `name`, or `name (2)`, `name (3)`… when an entry already uses it. */
5
+ export function uniqueEntryName(name, existing) {
6
+ const taken = new Set(existing.map((entry) => entry.name));
7
+ if (!taken.has(name)) {
8
+ return name;
9
+ }
10
+ let n = 2;
11
+ while (taken.has(`${name} (${n})`)) {
12
+ n++;
13
+ }
14
+ return `${name} (${n})`;
15
+ }
16
+ function isPlaceholderName(name) {
17
+ return !name || name === 'Effect' || name === 'Object3D' || name === 'Group';
18
+ }
19
+ /**
20
+ * Parses an asset into a new scene instance with its systems registered (paused). Returns null
21
+ * when the JSON holds no particle systems.
22
+ */
23
+ export function instantiateSceneEntry(scene, renderer, asset, existing) {
24
+ const { root, systems } = parseEffectFromJson(scene, renderer, asset.json, {
25
+ registerRenderer: true,
26
+ autoplay: false,
27
+ });
28
+ if (systems.length === 0) {
29
+ disposeLoadedEffect(root, systems, renderer, true);
30
+ return null;
31
+ }
32
+ const name = uniqueEntryName(isPlaceholderName(root.name) ? asset.name : root.name, existing);
33
+ root.name = name;
34
+ root.parent = null;
35
+ return { name, path: asset.path, assetId: asset.id, root, systems };
36
+ }
37
+ /** Builds an edit binding for one scene entry. */
38
+ export function bindingFromSceneEntry(entry) {
39
+ const main = entry.systems.find((s) => !s.onlyUsedByOther) ?? entry.systems[0];
40
+ return new EffectBinding(main, entry.systems.filter((s) => s !== main), entry.root);
41
+ }
42
+ /**
43
+ * Unregisters and disposes one scene entry — including systems the inspector added after the
44
+ * entry was created, which only the live emitter tree knows about.
45
+ */
46
+ export function disposeSceneEntry(entry, renderer) {
47
+ const systems = new Set(entry.systems);
48
+ QuarksUtil.runOnAllParticleEmitters(entry.root, (emitter) => {
49
+ systems.add(emitter.system);
50
+ });
51
+ disposeLoadedEffect(entry.root, [...systems], renderer, true);
52
+ }
53
+ /** Number of scene instances per asset id. */
54
+ export function countInstancesByAsset(entries) {
55
+ const counts = new Map();
56
+ for (const entry of entries) {
57
+ if (entry.assetId != null) {
58
+ counts.set(entry.assetId, (counts.get(entry.assetId) ?? 0) + 1);
59
+ }
60
+ }
61
+ return counts;
62
+ }
@@ -0,0 +1,12 @@
1
+ import type { Scene } from '@babylonjs/core/scene';
2
+ import type { BatchedRenderer } from 'babylon.quarks';
3
+ import type { EffectBinding } from './binding';
4
+ import { type PlaybackState } from './playback';
5
+ /**
6
+ * Stage Play/Pause shared by the viewport transport bar and the timeline. Pressing Play on a
7
+ * one-shot focus that already parked at its end replays it from the start.
8
+ */
9
+ export declare function toggleStagePlayback(binding: EffectBinding | null, scene: Scene, playback: PlaybackState, stagePlaying: boolean, setStagePlaying: (playing: boolean) => void): void;
10
+ /** Restarts the focused effect only; other visible effects keep playing. */
11
+ export declare function restartFocusPlayback(binding: EffectBinding, renderer: BatchedRenderer, scene: Scene, playback: PlaybackState): void;
12
+ export declare const PLAYBACK_SPEEDS: number[];
@@ -0,0 +1,36 @@
1
+ import { clearFocusFinished, isFocusFinished, setFocusElapsed } from './playback';
2
+ import { playFocus, scrubFocusTo } from './scrub';
3
+ /**
4
+ * Stage Play/Pause shared by the viewport transport bar and the timeline. Pressing Play on a
5
+ * one-shot focus that already parked at its end replays it from the start.
6
+ */
7
+ export function toggleStagePlayback(binding, scene, playback, stagePlaying, setStagePlaying) {
8
+ if (stagePlaying) {
9
+ playback.stagePlaying = false;
10
+ setStagePlaying(false);
11
+ return;
12
+ }
13
+ if (binding) {
14
+ const rootId = binding.root.uniqueId;
15
+ if (isFocusFinished(playback, rootId)) {
16
+ binding.restart();
17
+ clearFocusFinished(playback, rootId);
18
+ setFocusElapsed(playback, rootId, 0);
19
+ scene.render();
20
+ }
21
+ }
22
+ playback.stagePlaying = true;
23
+ setStagePlaying(true);
24
+ }
25
+ /** Restarts the focused effect only; other visible effects keep playing. */
26
+ export function restartFocusPlayback(binding, renderer, scene, playback) {
27
+ const rootId = binding.root.uniqueId;
28
+ scrubFocusTo(binding, renderer, 0);
29
+ setFocusElapsed(playback, rootId, 0);
30
+ clearFocusFinished(playback, rootId);
31
+ if (playback.stagePlaying) {
32
+ playFocus(binding);
33
+ }
34
+ scene.render();
35
+ }
36
+ export const PLAYBACK_SPEEDS = [0.1, 0.25, 0.5, 1, 2, 4];
@@ -1,8 +1,8 @@
1
1
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
2
  import type { Scene } from '@babylonjs/core/scene';
3
- import type { GalleryEntry } from './loadEffectGallery';
3
+ import type { SceneEntry } from './sceneEntries';
4
4
  /**
5
- * Viewport pick/hover for in-scene gallery effect roots only (not child emitters).
5
+ * Viewport pick/hover for visible scene effect roots only (not child emitters).
6
6
  * Hover uses the same ground ring as focus selection, in yellow.
7
7
  */
8
8
  export declare class GalleryViewportPicker {
@@ -12,8 +12,8 @@ export declare class GalleryViewportPicker {
12
12
  private hoveredRoot;
13
13
  private syncKey;
14
14
  constructor(scene: Scene);
15
- /** Rebuilds root pick colliders when the gallery changes. */
16
- sync(entries: GalleryEntry[], soloRoot: TransformNode | null, syncKey: string): void;
15
+ /** Rebuilds root pick colliders when the scene changes. */
16
+ sync(entries: SceneEntry[], soloRoot: TransformNode | null, syncKey: string): void;
17
17
  /** Yellow focus-style ring under the hovered effect root. */
18
18
  setHover(effectRoot: TransformNode | null): void;
19
19
  follow(): void;
@@ -4,7 +4,7 @@ import { GROUND_RING_RADIUS, GalleryHoverMarker } from './gallerySelectionMarker
4
4
  /** Invisible pick proxy size — matches the ground ring footprint. */
5
5
  const PICK_DIAMETER = GROUND_RING_RADIUS * 2;
6
6
  /**
7
- * Viewport pick/hover for in-scene gallery effect roots only (not child emitters).
7
+ * Viewport pick/hover for visible scene effect roots only (not child emitters).
8
8
  * Hover uses the same ground ring as focus selection, in yellow.
9
9
  */
10
10
  export class GalleryViewportPicker {
@@ -15,7 +15,7 @@ export class GalleryViewportPicker {
15
15
  this.scene = scene;
16
16
  this.hoverMarker = new GalleryHoverMarker(scene);
17
17
  }
18
- /** Rebuilds root pick colliders when the gallery changes. */
18
+ /** Rebuilds root pick colliders when the scene changes. */
19
19
  sync(entries, soloRoot, syncKey) {
20
20
  if (syncKey === this.syncKey) {
21
21
  return;
@@ -24,7 +24,7 @@ export class GalleryViewportPicker {
24
24
  this.clearHandles();
25
25
  if (entries.length > 0) {
26
26
  for (const entry of entries) {
27
- if (!entry.inScene || entry.root.isDisposed()) {
27
+ if (entry.root.isDisposed() || !entry.root.isEnabled(false)) {
28
28
  continue;
29
29
  }
30
30
  this.createHandle(entry.root);
package/dist/index.d.ts CHANGED
@@ -6,8 +6,12 @@ export type { GradientStop } from './core/colors';
6
6
  export { buildEffectTree, collectSystems, serializeEffectForest, serializeEffectTree } from './core/effectTree';
7
7
  export type { EffectTreeNode } from './core/effectTree';
8
8
  export { EffectHistory } from './core/history';
9
+ export type { HistoryStacks } from './core/history';
10
+ export { IndexedDbProjectStore } from './core/indexedDbProjectStore';
9
11
  export { applyRendererMaterial, getMaterialLabel } from './core/material';
10
12
  export type { RendererMaterialPatch } from './core/material';
13
+ export { formatBytes } from './core/projectStore';
14
+ export type { ProjectMeta, ProjectStore, StorageEstimate, StoredAsset, StoredScene, StoredSceneEntry, } from './core/projectStore';
11
15
  export { DEFAULT_SHAPE_PARAMS, SHAPE_PARAM_KEYS, SHAPE_TYPES, createShape, getShapeType, readShapeParams, } from './core/shapes';
12
16
  export type { ShapeParams, ShapeType } from './core/shapes';
13
17
  export { createChildSystem } from './core/systems';
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ export { GroundPlaneResolver, ensureGroundResolver } from './core/collision';
3
3
  export { DEFAULT_GRADIENT_STOPS, buildColorOverLife, buildGradient, findBehavior, hexToRgb, rgbToHex, rgbaToVector4, stopToCss, stopsToCssGradient, } from './core/colors';
4
4
  export { buildEffectTree, collectSystems, serializeEffectForest, serializeEffectTree } from './core/effectTree';
5
5
  export { EffectHistory } from './core/history';
6
+ export { IndexedDbProjectStore } from './core/indexedDbProjectStore';
6
7
  export { applyRendererMaterial, getMaterialLabel } from './core/material';
8
+ export { formatBytes } from './core/projectStore';
7
9
  export { DEFAULT_SHAPE_PARAMS, SHAPE_PARAM_KEYS, SHAPE_TYPES, createShape, getShapeType, readShapeParams, } from './core/shapes';
8
10
  export { createChildSystem } from './core/systems';
9
11
  export { ensurePortableTextureUrl } from './core/textureExport';
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { type AssetDragPayload } from '../core/assetTree';
3
+ import { type AssetLoadProgress, type EffectAsset } from '../core/assets';
4
+ export interface AssetsPanelProps {
5
+ assets: EffectAsset[];
6
+ /** Scene instance count per asset id. */
7
+ instanceCounts: ReadonlyMap<number, number>;
8
+ loading: AssetLoadProgress | null;
9
+ folderInputRef: React.RefObject<HTMLInputElement | null>;
10
+ onImportFiles: (files: FileList | File[]) => void;
11
+ onImportFolder: () => void;
12
+ /** Adds a new instance of each asset in the payload to the scene. */
13
+ onAddToScene: (payload: AssetDragPayload) => void;
14
+ onDelete: (payload: AssetDragPayload) => void;
15
+ /** Opens the asset on its own to edit it (double-click). Without it, double-click adds to the scene. */
16
+ onOpenPrefab?: (asset: EffectAsset) => void;
17
+ }
18
+ /**
19
+ * Bottom-dock asset browser: folder tree on the left, the open folder's effects as tiles on the
20
+ * right. Drag a tile into the viewport (as many times as needed) or right-click it for actions.
21
+ */
22
+ export declare function AssetsPanel(props: AssetsPanelProps): React.ReactElement;
@@ -0,0 +1,252 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ChevronDownIcon, ChevronRightIcon, ExclamationTriangleIcon, FolderIcon, FolderOpenIcon, SparklesIcon, } from '@heroicons/react/24/solid';
3
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { setAssetDragSession } from '../core/assetDragSession';
5
+ import { buildAssetTree, collectFolderAssets, folderChildren, searchAssets, writeAssetDrag, } from '../core/assetTree';
6
+ import { assetIconUrl } from '../core/assets';
7
+ import { ContextMenu } from './ContextMenu';
8
+ import { iconStyle } from './icons';
9
+ import { buttonStyle, hiddenFileInputStyle, inputStyle, theme } from './theme';
10
+ const TREE_WIDTH = 200;
11
+ const TILE_WIDTH = 92;
12
+ function beginAssetDrag(e, payload) {
13
+ writeAssetDrag(e.dataTransfer, payload);
14
+ setAssetDragSession(payload);
15
+ }
16
+ function endAssetDrag() {
17
+ setAssetDragSession(null);
18
+ }
19
+ function folderName(path) {
20
+ return path.split('/').filter(Boolean).pop() ?? 'Assets';
21
+ }
22
+ /** Main texture of the effect, or a sparkle when it has none (or it fails to load). */
23
+ function AssetIcon(props) {
24
+ const url = assetIconUrl(props.asset);
25
+ const [failed, setFailed] = useState(false);
26
+ if (!url || failed) {
27
+ return _jsx(SparklesIcon, { style: { ...iconStyle(26), color: theme.accent, opacity: 0.8 } });
28
+ }
29
+ return (_jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", draggable: false, onError: () => setFailed(true), style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }));
30
+ }
31
+ function Tile(props) {
32
+ return (_jsxs("button", { type: "button", draggable: true, className: "qe-hover-bg", title: props.title, onClick: props.onSelect, onDoubleClick: props.onOpen, onContextMenu: props.onContextMenu, onDragStart: props.onDragStart, onDragEnd: endAssetDrag, style: {
33
+ position: 'relative',
34
+ display: 'flex',
35
+ flexDirection: 'column',
36
+ alignItems: 'center',
37
+ gap: 5,
38
+ width: '100%',
39
+ padding: '6px 4px',
40
+ border: `1px solid ${props.selected ? theme.borderActive : 'transparent'}`,
41
+ borderRadius: 8,
42
+ background: props.selected ? 'rgba(120, 165, 255, 0.18)' : 'transparent',
43
+ color: theme.text,
44
+ cursor: 'grab',
45
+ fontFamily: theme.font,
46
+ }, children: [_jsx("span", { style: {
47
+ width: 56,
48
+ height: 56,
49
+ display: 'flex',
50
+ alignItems: 'center',
51
+ justifyContent: 'center',
52
+ borderRadius: 6,
53
+ background: 'rgba(0, 0, 0, 0.35)',
54
+ overflow: 'hidden',
55
+ }, children: props.icon }), _jsx("span", { style: {
56
+ width: '100%',
57
+ fontSize: 11.5,
58
+ lineHeight: 1.25,
59
+ textAlign: 'center',
60
+ wordBreak: 'break-word',
61
+ display: '-webkit-box',
62
+ WebkitLineClamp: 2,
63
+ WebkitBoxOrient: 'vertical',
64
+ overflow: 'hidden',
65
+ }, children: props.label }), props.subtitle && (_jsx("span", { style: {
66
+ width: '100%',
67
+ fontSize: 10,
68
+ color: theme.textDim,
69
+ textAlign: 'center',
70
+ overflow: 'hidden',
71
+ textOverflow: 'ellipsis',
72
+ whiteSpace: 'nowrap',
73
+ }, children: props.subtitle })), props.badge && (_jsx("span", { style: { position: 'absolute', top: 4, right: 6, fontSize: 10.5, color: theme.accent }, children: props.badge })), props.warning && (_jsx("span", { title: props.warning, style: { position: 'absolute', top: 4, left: 5, color: '#ffcc66' }, children: _jsx(ExclamationTriangleIcon, { style: iconStyle(12) }) }))] }));
74
+ }
75
+ function FolderTreeRow(props) {
76
+ const { folder, depth, currentPath, collapsed, onToggle, onOpen, onContextMenu } = props;
77
+ const subfolders = folder.children.filter((node) => node.kind === 'folder');
78
+ const open = !collapsed.has(folder.path);
79
+ const current = currentPath === folder.path;
80
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "qe-hover-bg", title: folder.path, draggable: true, onClick: () => onOpen(folder.path), onContextMenu: (e) => onContextMenu(e, folder), onDragStart: (e) => beginAssetDrag(e, { kind: 'folder', path: folder.path }), onDragEnd: endAssetDrag, style: {
81
+ display: 'flex',
82
+ alignItems: 'center',
83
+ gap: 5,
84
+ height: 26,
85
+ padding: `0 8px 0 ${6 + depth * 12}px`,
86
+ fontSize: 12,
87
+ cursor: 'pointer',
88
+ color: current ? theme.text : theme.textDim,
89
+ background: current ? 'rgba(120, 165, 255, 0.2)' : undefined,
90
+ }, children: [_jsx("span", { style: {
91
+ width: 12,
92
+ display: 'inline-flex',
93
+ visibility: subfolders.length > 0 ? 'visible' : 'hidden',
94
+ }, onClick: (e) => {
95
+ e.stopPropagation();
96
+ onToggle(folder.path);
97
+ }, children: open ? _jsx(ChevronDownIcon, { style: iconStyle(11) }) : _jsx(ChevronRightIcon, { style: iconStyle(11) }) }), _jsx("span", { style: { display: 'inline-flex', color: theme.accent }, children: current ? _jsx(FolderOpenIcon, { style: iconStyle(13) }) : _jsx(FolderIcon, { style: iconStyle(13) }) }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: folder.name })] }), open &&
98
+ subfolders.map((child) => (_jsx(FolderTreeRow, { ...props, folder: child, depth: depth + 1 }, child.path)))] }));
99
+ }
100
+ /**
101
+ * Bottom-dock asset browser: folder tree on the left, the open folder's effects as tiles on the
102
+ * right. Drag a tile into the viewport (as many times as needed) or right-click it for actions.
103
+ */
104
+ export function AssetsPanel(props) {
105
+ const { assets, instanceCounts, loading, folderInputRef, onImportFiles, onImportFolder, onAddToScene, onDelete, onOpenPrefab, } = props;
106
+ const [query, setQuery] = useState('');
107
+ const [currentPath, setCurrentPath] = useState('');
108
+ const [collapsed, setCollapsed] = useState(() => new Set());
109
+ const [selectedKey, setSelectedKey] = useState(null);
110
+ const [menu, setMenu] = useState(null);
111
+ const fileInputRef = useRef(null);
112
+ const isLoading = loading !== null;
113
+ const searching = query.trim().length > 0;
114
+ const tree = useMemo(() => buildAssetTree(assets), [assets]);
115
+ const rootFolder = useMemo(() => ({ kind: 'folder', name: 'Assets', path: '', children: tree }), [tree]);
116
+ // A deleted folder falls back to the top level.
117
+ const children = folderChildren(tree, currentPath);
118
+ const openPath = children ? currentPath : '';
119
+ const content = children ?? tree;
120
+ const results = useMemo(() => (searching ? searchAssets(assets, query) : []), [assets, query, searching]);
121
+ const closeMenu = useCallback(() => setMenu(null), []);
122
+ const toggleFolder = useCallback((path) => {
123
+ setCollapsed((prev) => {
124
+ const next = new Set(prev);
125
+ if (next.has(path)) {
126
+ next.delete(path);
127
+ }
128
+ else {
129
+ next.add(path);
130
+ }
131
+ return next;
132
+ });
133
+ }, []);
134
+ const openFolder = useCallback((path) => {
135
+ setCurrentPath(path);
136
+ setQuery('');
137
+ setSelectedKey(null);
138
+ }, []);
139
+ const showMenu = (e, items) => {
140
+ e.preventDefault();
141
+ e.stopPropagation();
142
+ setMenu({ x: e.clientX, y: e.clientY, items });
143
+ };
144
+ const assetMenu = (e, asset) => {
145
+ setSelectedKey(`asset:${asset.id}`);
146
+ showMenu(e, [
147
+ ...(onOpenPrefab ? [{ label: 'Open prefab', onSelect: () => onOpenPrefab(asset) }] : []),
148
+ { label: 'Add to scene', onSelect: () => onAddToScene({ kind: 'asset', id: asset.id }) },
149
+ { label: 'Delete from Assets', danger: true, onSelect: () => onDelete({ kind: 'asset', id: asset.id }) },
150
+ ]);
151
+ };
152
+ const folderMenu = (e, folder) => {
153
+ const count = folder.path ? collectFolderAssets(folder).length : assets.length;
154
+ setSelectedKey(`folder:${folder.path}`);
155
+ const items = [
156
+ { label: 'Open', onSelect: () => openFolder(folder.path) },
157
+ {
158
+ label: `Add all to scene (${count})`,
159
+ disabled: count === 0 || !folder.path,
160
+ onSelect: () => onAddToScene({ kind: 'folder', path: folder.path }),
161
+ },
162
+ ];
163
+ if (folder.path) {
164
+ items.push({
165
+ label: 'Delete folder from Assets',
166
+ danger: true,
167
+ onSelect: () => onDelete({ kind: 'folder', path: folder.path }),
168
+ });
169
+ }
170
+ showMenu(e, items);
171
+ };
172
+ const backgroundMenu = (e) => showMenu(e, [
173
+ { label: 'Import effect…', disabled: isLoading, onSelect: () => fileInputRef.current?.click() },
174
+ { label: 'Import folder…', disabled: isLoading, onSelect: onImportFolder },
175
+ ]);
176
+ const assetTile = (asset, subtitle) => {
177
+ const key = `asset:${asset.id}`;
178
+ const instances = instanceCounts.get(asset.id) ?? 0;
179
+ return (_jsx(Tile, { label: asset.name, subtitle: subtitle, title: `${asset.path}\nDrag into the viewport · double-click ${onOpenPrefab ? 'opens the prefab' : 'adds to the scene'}`, selected: selectedKey === key, icon: _jsx(AssetIcon, { asset: asset }), badge: instances > 0 ? `×${instances}` : undefined, warning: asset.missingFiles.length > 0
180
+ ? `References files that were not imported — import the whole export folder:\n${asset.missingFiles.join('\n')}`
181
+ : undefined, onSelect: () => setSelectedKey(key), onOpen: () => (onOpenPrefab ? onOpenPrefab(asset) : onAddToScene({ kind: 'asset', id: asset.id })), onContextMenu: (e) => assetMenu(e, asset), onDragStart: (e) => beginAssetDrag(e, { kind: 'asset', id: asset.id }) }, key));
182
+ };
183
+ const folderTile = (folder) => {
184
+ const key = `folder:${folder.path}`;
185
+ const count = collectFolderAssets(folder).length;
186
+ return (_jsx(Tile, { label: folder.name, title: `${folder.path} — ${count} effect(s)\nDouble-click to open · drag to place them all`, selected: selectedKey === key, icon: _jsx(FolderIcon, { style: { ...iconStyle(34), color: theme.accent } }), badge: String(count), onSelect: () => setSelectedKey(key), onOpen: () => openFolder(folder.path), onContextMenu: (e) => folderMenu(e, folder), onDragStart: (e) => beginAssetDrag(e, { kind: 'folder', path: folder.path }) }, key));
187
+ };
188
+ const fileLabelStyle = {
189
+ ...buttonStyle,
190
+ position: 'relative',
191
+ display: 'inline-flex',
192
+ alignItems: 'center',
193
+ padding: '4px 10px',
194
+ cursor: isLoading ? 'not-allowed' : 'pointer',
195
+ opacity: isLoading ? 0.5 : 1,
196
+ };
197
+ const takeFiles = (e) => {
198
+ // Snapshot before clearing — FileList is live and empties with value=''.
199
+ const files = Array.from(e.target.files ?? []);
200
+ e.target.value = '';
201
+ if (files.length > 0) {
202
+ onImportFiles(files);
203
+ }
204
+ };
205
+ const crumbs = openPath ? ['', ...openPath.split('/').map((_, i, parts) => parts.slice(0, i + 1).join('/'))] : [''];
206
+ const gridItems = searching
207
+ ? results.map((asset) => assetTile(asset, asset.path.split('/').slice(0, -1).join('/') || undefined))
208
+ : content.map((node) => (node.kind === 'folder' ? folderTile(node) : assetTile(node.asset)));
209
+ return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '6px 10px' }, children: [_jsxs("label", { className: "qe-hover", style: fileLabelStyle, title: "Effect .json files (plus any texture files they use)", children: ["Import effect", _jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: ".json,image/*", disabled: isLoading, tabIndex: -1, style: hiddenFileInputStyle, onChange: takeFiles })] }), _jsxs("label", { className: "qe-hover", style: fileLabelStyle, title: "A folder of effects \u2014 subfolders and shared asset files are kept", onClick: (e) => {
210
+ if (typeof window.showDirectoryPicker ===
211
+ 'function') {
212
+ e.preventDefault();
213
+ onImportFolder();
214
+ }
215
+ }, children: ["Import folder", _jsx("input", { ref: (el) => {
216
+ folderInputRef.current = el;
217
+ if (el) {
218
+ el.setAttribute('webkitdirectory', '');
219
+ el.setAttribute('directory', '');
220
+ }
221
+ }, type: "file", multiple: true, disabled: isLoading, tabIndex: -1, style: hiddenFileInputStyle, onChange: takeFiles })] }), _jsx("input", { className: "qe-hover", style: { ...inputStyle, width: 220, padding: '4px 8px' }, placeholder: "Search all folders\u2026", value: query, onChange: (e) => setQuery(e.target.value) }), _jsx("span", { style: { fontSize: 12, color: theme.textDim, marginLeft: 'auto', whiteSpace: 'nowrap' }, children: isLoading
222
+ ? `Loading… ${loading.done} / ${loading.total || '…'} files`
223
+ : `${assets.length} asset(s) · drag into the viewport · right-click for actions` })] }), _jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0, borderTop: `1px solid ${theme.border}` }, children: [_jsx("div", { style: {
224
+ width: TREE_WIDTH,
225
+ flexShrink: 0,
226
+ overflowY: 'auto',
227
+ borderRight: `1px solid ${theme.border}`,
228
+ padding: '4px 0',
229
+ }, onContextMenu: backgroundMenu, children: _jsx(FolderTreeRow, { folder: rootFolder, depth: 0, currentPath: searching ? '\0' : openPath, collapsed: collapsed, onToggle: toggleFolder, onOpen: openFolder, onContextMenu: folderMenu }) }), _jsxs("div", { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }, onClick: () => setSelectedKey(null), onContextMenu: backgroundMenu, children: [_jsx("div", { style: {
230
+ display: 'flex',
231
+ alignItems: 'center',
232
+ gap: 4,
233
+ padding: '5px 12px',
234
+ fontSize: 12,
235
+ color: theme.textDim,
236
+ borderBottom: `1px solid ${theme.border}`,
237
+ whiteSpace: 'nowrap',
238
+ overflow: 'hidden',
239
+ }, children: searching ? (_jsxs("span", { children: [results.length, " result(s) for \u201C", query.trim(), "\u201D"] })) : (crumbs.map((path, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx("span", { style: { opacity: 0.5 }, children: "/" }), _jsx("span", { className: "qe-hover-bg", style: {
240
+ padding: '1px 4px',
241
+ borderRadius: 4,
242
+ cursor: 'pointer',
243
+ color: i === crumbs.length - 1 ? theme.text : theme.textDim,
244
+ }, onClick: (e) => {
245
+ e.stopPropagation();
246
+ openFolder(path);
247
+ }, children: folderName(path) })] }, path || 'root')))) }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: 8 }, children: assets.length === 0 && !isLoading ? (_jsxs("div", { style: { padding: '4px 6px', fontSize: 12, color: theme.textDim, lineHeight: 1.5 }, children: ["No assets yet. ", _jsx("strong", { children: "Import effect" }), " or ", _jsx("strong", { children: "Import folder" }), " \u2014 then drag an effect into the viewport as many times as you need."] })) : gridItems.length === 0 ? (_jsx("div", { style: { padding: '4px 6px', fontSize: 12, color: theme.textDim }, children: searching ? 'No matches.' : 'This folder is empty.' })) : (_jsx("div", { style: {
248
+ display: 'grid',
249
+ gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_WIDTH}px, 1fr))`,
250
+ gap: 6,
251
+ }, onClick: (e) => e.stopPropagation(), children: gridItems })) })] })] }), menu && _jsx(ContextMenu, { x: menu.x, y: menu.y, items: menu.items, onClose: closeMenu })] }));
252
+ }
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ export interface ChoiceDialogAction {
3
+ label: string;
4
+ onSelect: () => void;
5
+ /** Accent button — also what Enter picks. */
6
+ primary?: boolean;
7
+ danger?: boolean;
8
+ }
9
+ export interface ChoiceDialogProps {
10
+ title: string;
11
+ message: React.ReactNode;
12
+ /** Shown left to right; put the cancel action first. */
13
+ actions: ChoiceDialogAction[];
14
+ /** Escape / backdrop click. */
15
+ onCancel: () => void;
16
+ }
17
+ /** Confirmation modal with several outcomes (e.g. Save / Don't save / Cancel). */
18
+ export declare function ChoiceDialog(props: ChoiceDialogProps): React.ReactElement;
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { buttonStyle, theme } from './theme';
4
+ /** Confirmation modal with several outcomes (e.g. Save / Don't save / Cancel). */
5
+ export function ChoiceDialog(props) {
6
+ const { title, message, actions, onCancel } = props;
7
+ useEffect(() => {
8
+ const onKey = (e) => {
9
+ if (e.key === 'Escape') {
10
+ e.stopPropagation();
11
+ onCancel();
12
+ }
13
+ else if (e.key === 'Enter') {
14
+ const primary = actions.find((action) => action.primary);
15
+ if (primary) {
16
+ e.preventDefault();
17
+ primary.onSelect();
18
+ }
19
+ }
20
+ };
21
+ window.addEventListener('keydown', onKey, true);
22
+ return () => window.removeEventListener('keydown', onKey, true);
23
+ }, [actions, onCancel]);
24
+ return (_jsx("div", { style: {
25
+ position: 'fixed',
26
+ inset: 0,
27
+ background: 'rgba(3, 5, 12, 0.6)',
28
+ display: 'flex',
29
+ alignItems: 'center',
30
+ justifyContent: 'center',
31
+ zIndex: 1000,
32
+ fontFamily: theme.font,
33
+ }, onClick: onCancel, children: _jsxs("div", { role: "dialog", "aria-label": title, style: {
34
+ background: theme.panelBg,
35
+ border: `1px solid ${theme.border}`,
36
+ borderRadius: 10,
37
+ padding: 16,
38
+ width: 380,
39
+ maxWidth: 'calc(100vw - 32px)',
40
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.5)',
41
+ }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { style: { fontSize: 14, fontWeight: 600, color: theme.text, marginBottom: 8 }, children: title }), _jsx("div", { style: { fontSize: 12.5, color: theme.textDim, lineHeight: 1.5, marginBottom: 16 }, children: message }), _jsx("div", { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap' }, children: actions.map((action) => (_jsx("button", { className: "qe-hover", style: {
42
+ ...buttonStyle,
43
+ ...(action.primary
44
+ ? { background: theme.accent, color: '#0a1128', border: `1px solid ${theme.accent}` }
45
+ : {}),
46
+ ...(action.danger ? { border: '1px solid #b04a4a', color: '#ffb0b0' } : {}),
47
+ }, onClick: action.onSelect, children: action.label }, action.label))) })] }) }));
48
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ export interface ContextMenuItem {
3
+ label: string;
4
+ onSelect: () => void;
5
+ danger?: boolean;
6
+ disabled?: boolean;
7
+ }
8
+ export interface ContextMenuProps {
9
+ /** Client coordinates of the right-click. */
10
+ x: number;
11
+ y: number;
12
+ items: ContextMenuItem[];
13
+ onClose: () => void;
14
+ }
15
+ /** Right-click menu at the pointer; closes on outside click, Escape, scroll or window blur. */
16
+ export declare function ContextMenu(props: ContextMenuProps): React.ReactElement;