@vgai/editor 0.4.0-canary.20260715.0 → 0.4.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/dist/assets/{axe-C7YBtOuM.js → axe-BJjvBeOZ.js} +1 -1
- package/dist/assets/{browserAll-DDtGGE48.js → browserAll-Dn0pgD87.js} +1 -1
- package/dist/assets/{create-runtime-DbTlBlKG.js → create-runtime-BF9OD-U8.js} +1 -1
- package/dist/assets/{game-Dch9sAPc.js → game-B346oj_f.js} +1 -1
- package/dist/assets/{game-BE0r7DMI.js → game-BCNN63uV.js} +1 -1
- package/dist/assets/{game-SAFn90pA.js → game-BR0GBNgF.js} +1 -1
- package/dist/assets/{game-Cqzj_lpD.js → game-CLNm2g0y.js} +1 -1
- package/dist/assets/{game-CcHfYxjP.js → game-DQxhCYsZ.js} +1 -1
- package/dist/assets/{game-BdTHR2_X.js → game-DeTGdgYt.js} +1 -1
- package/dist/assets/{game-loop-DMIgSMV-.js → game-loop-t5W_oqxz.js} +1 -1
- package/dist/assets/{game-source-Dk83w26t.js → game-source-DzE9taML.js} +1 -1
- package/dist/assets/{game-DSZdeCHu.js → game-u6c2Patu.js} +1 -1
- package/dist/assets/{game-C3iQGSqo.js → game-umrHIqFB.js} +1 -1
- package/dist/assets/{index-C-LhOTBN.js → index-CgcH1RpF.js} +1 -1
- package/dist/assets/{index-CDkxceqE.js → index-D3tcNB2T.js} +1 -1
- package/dist/assets/{index-CQEfi217.js → index-DbQzGLxv.js} +771 -771
- package/dist/assets/{index-4idIsHHJ.js → index-Fc8Ezr20.js} +1 -1
- package/dist/assets/index-bAGhEnnO.css +1 -0
- package/dist/assets/{ingest-siblings-s0P5I1Ub.js → ingest-siblings-CW_xbvrF.js} +1 -1
- package/dist/assets/{mount-manifest-DXMOlF79.js → mount-manifest-DgUscyaD.js} +1 -1
- package/dist/assets/{react-18-C9eHn8x1.js → react-18-C4tVCii_.js} +1 -1
- package/dist/assets/webworkerAll-BA4aq7xq.js +1 -0
- package/dist/assets/{world-documents-GM0a9Kv7.js → world-documents-BSaA5YIF.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/packaged.mjs +21 -1
- package/package.json +3 -3
- package/server/packaged.ts +20 -0
- package/src/asset-editor-context.tsx +130 -0
- package/src/asset-preview.ts +4 -0
- package/src/asset-selection.ts +9 -4
- package/src/asset-workflow/thumbnail-system.ts +16 -7
- package/src/components/AssetBrowser.tsx +330 -539
- package/src/components/AssetEditorShell.tsx +42 -112
- package/src/components/AssetImportDetails.tsx +24 -6
- package/src/components/GameHierarchy.tsx +26 -1
- package/src/components/Inspector.tsx +38 -2
- package/src/components/OnlineAssetBrowser.tsx +295 -343
- package/src/components/WorkspaceDock.tsx +6 -12
- package/src/components/asset-documents.tsx +41 -12
- package/src/components/asset-selection-section.tsx +34 -1
- package/src/components/asset-viewers/ModelViewer.tsx +7 -4
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +233 -205
- package/src/components/asset-workflow.css +197 -264
- package/src/editor-api.ts +1 -1
- package/template/vgai.game.json +1 -1
- package/dist/assets/index-Dvi-V0NE.css +0 -1
- package/dist/assets/webworkerAll-Fn0e-ouF.js +0 -1
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
faArrowLeft,
|
|
3
|
-
faBookOpen,
|
|
4
3
|
faBoxArchive,
|
|
5
|
-
faChevronRight,
|
|
6
4
|
faCubes,
|
|
5
|
+
faEllipsis,
|
|
7
6
|
faFile,
|
|
8
7
|
faFileCode,
|
|
9
8
|
faFileImage,
|
|
@@ -13,11 +12,9 @@ import {
|
|
|
13
12
|
faPaintbrush,
|
|
14
13
|
faPuzzlePiece,
|
|
15
14
|
faImage as faSceneIcon,
|
|
16
|
-
faTable,
|
|
17
15
|
faTableCellsLarge,
|
|
18
|
-
faTrash,
|
|
19
16
|
} from '@fortawesome/free-solid-svg-icons';
|
|
20
|
-
import { useCallback, useEffect, useMemo, useRef, useState
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
21
18
|
import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
|
|
22
19
|
import {
|
|
23
20
|
type AssetCapabilityKind,
|
|
@@ -36,7 +33,6 @@ import {
|
|
|
36
33
|
ThumbnailJobQueue,
|
|
37
34
|
} from '../asset-workflow/thumbnail-system';
|
|
38
35
|
import {
|
|
39
|
-
accent,
|
|
40
36
|
Button,
|
|
41
37
|
border,
|
|
42
38
|
EditorIcon,
|
|
@@ -59,20 +55,7 @@ import { isEditableTarget, setActiveScope } from '../hotkeys';
|
|
|
59
55
|
import { getModelThumbnailRenderer, modelThumbnailFormat } from '../model-thumbnail';
|
|
60
56
|
import { getCurrentProject } from '../project-manager';
|
|
61
57
|
import { getStorageBackend } from '../storage';
|
|
62
|
-
import {
|
|
63
|
-
getProjectStoryModules,
|
|
64
|
-
refreshProjectStories,
|
|
65
|
-
subscribeProjectStoryModules,
|
|
66
|
-
} from '../stories/story-registry';
|
|
67
58
|
import { openAssetDocument } from './asset-documents';
|
|
68
|
-
import {
|
|
69
|
-
dataFilesVersion,
|
|
70
|
-
loadedDataFiles,
|
|
71
|
-
openDataDocument,
|
|
72
|
-
refreshDataFiles,
|
|
73
|
-
subscribeDataFiles,
|
|
74
|
-
} from './data-documents';
|
|
75
|
-
import { openStoryDocument } from './story-documents';
|
|
76
59
|
import './asset-workflow.css';
|
|
77
60
|
|
|
78
61
|
// --- Helpers ---
|
|
@@ -89,7 +72,7 @@ type AssetKind =
|
|
|
89
72
|
| 'source'
|
|
90
73
|
| 'unknown';
|
|
91
74
|
|
|
92
|
-
type BrowserKind = AssetKind
|
|
75
|
+
type BrowserKind = AssetKind;
|
|
93
76
|
type ViewableAssetKind = Exclude<AssetKind, 'folder' | 'unknown'>;
|
|
94
77
|
|
|
95
78
|
function getAssetKind(entry: AssetEntry): AssetKind {
|
|
@@ -106,8 +89,6 @@ const assetIconColors: Record<BrowserKind, string> = {
|
|
|
106
89
|
audio: '#34d399',
|
|
107
90
|
image: '#f472b6',
|
|
108
91
|
json: '#fbbf24',
|
|
109
|
-
data: '#22c55e',
|
|
110
|
-
story: '#a78bfa',
|
|
111
92
|
unknown: '#6b7280',
|
|
112
93
|
source: '#fb923c',
|
|
113
94
|
};
|
|
@@ -121,8 +102,6 @@ const assetIconMap: Record<BrowserKind, typeof faFolder> = {
|
|
|
121
102
|
audio: faMusic,
|
|
122
103
|
image: faFileImage,
|
|
123
104
|
json: faFileCode,
|
|
124
|
-
data: faTable,
|
|
125
|
-
story: faBookOpen,
|
|
126
105
|
unknown: faFile,
|
|
127
106
|
source: faBoxArchive,
|
|
128
107
|
};
|
|
@@ -139,7 +118,12 @@ function AssetIcon({ kind, size = 24 }: { kind: BrowserKind; size?: number }) {
|
|
|
139
118
|
const projectThumbnailQueue = new ThumbnailJobQueue<string>((url) => {
|
|
140
119
|
const format = modelThumbnailFormat(url);
|
|
141
120
|
if (!format) return Promise.reject(new Error(`No model preview loader for ${url}.`));
|
|
142
|
-
return getModelThumbnailRenderer().render({
|
|
121
|
+
return getModelThumbnailRenderer().render({
|
|
122
|
+
url,
|
|
123
|
+
format,
|
|
124
|
+
background: 'neutral',
|
|
125
|
+
output: 'webp',
|
|
126
|
+
});
|
|
143
127
|
}, 2);
|
|
144
128
|
|
|
145
129
|
function ModelThumbnail({ url }: { url: string }) {
|
|
@@ -195,7 +179,7 @@ function ModelThumbnail({ url }: { url: string }) {
|
|
|
195
179
|
setManifestPath(null);
|
|
196
180
|
setManifestRejected(true);
|
|
197
181
|
}}
|
|
198
|
-
style={{
|
|
182
|
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
199
183
|
/>
|
|
200
184
|
);
|
|
201
185
|
}
|
|
@@ -229,7 +213,7 @@ function ModelThumbnail({ url }: { url: string }) {
|
|
|
229
213
|
<img
|
|
230
214
|
src={src}
|
|
231
215
|
alt="model preview"
|
|
232
|
-
style={{
|
|
216
|
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
233
217
|
/>
|
|
234
218
|
);
|
|
235
219
|
}
|
|
@@ -261,23 +245,19 @@ function projectAssetHealth(name: string, path: string): ProjectAssetHealth {
|
|
|
261
245
|
}
|
|
262
246
|
|
|
263
247
|
interface PersistedAssetBrowserState {
|
|
264
|
-
currentRoot: AssetRoot | null;
|
|
265
248
|
currentPath: string;
|
|
266
249
|
viewMode: AssetViewMode;
|
|
267
250
|
sort: AssetSort;
|
|
268
251
|
filter: AssetFilter;
|
|
269
252
|
density: number;
|
|
270
|
-
expanded: string[];
|
|
271
253
|
}
|
|
272
254
|
|
|
273
255
|
const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
|
|
274
|
-
currentRoot: null,
|
|
275
256
|
currentPath: '',
|
|
276
257
|
viewMode: 'grid',
|
|
277
258
|
sort: 'name',
|
|
278
259
|
filter: 'all',
|
|
279
260
|
density: 96,
|
|
280
|
-
expanded: ['public'],
|
|
281
261
|
};
|
|
282
262
|
|
|
283
263
|
function readAssetBrowserState(): PersistedAssetBrowserState {
|
|
@@ -286,7 +266,18 @@ function readAssetBrowserState(): PersistedAssetBrowserState {
|
|
|
286
266
|
const parsed = JSON.parse(
|
|
287
267
|
localStorage.getItem(projectAssetBrowserStateKey(projectId)) ?? '{}',
|
|
288
268
|
) as Partial<PersistedAssetBrowserState>;
|
|
289
|
-
|
|
269
|
+
const legacyViewMode = (parsed as Record<string, unknown>)['viewMode'];
|
|
270
|
+
const legacyRoot = (parsed as Record<string, unknown>)['currentRoot'];
|
|
271
|
+
return {
|
|
272
|
+
...DEFAULT_BROWSER_STATE,
|
|
273
|
+
...parsed,
|
|
274
|
+
// v1 briefly persisted synthetic `data` / `stories` roots and a
|
|
275
|
+
// project-root landing page. Assets now opens directly at the real
|
|
276
|
+
// public asset root, so those old locations migrate home once.
|
|
277
|
+
currentPath:
|
|
278
|
+
legacyRoot === undefined || legacyRoot === 'public' ? (parsed.currentPath ?? '') : '',
|
|
279
|
+
viewMode: legacyViewMode === 'columns' ? 'list' : legacyViewMode === 'list' ? 'list' : 'grid',
|
|
280
|
+
};
|
|
290
281
|
} catch {
|
|
291
282
|
return DEFAULT_BROWSER_STATE;
|
|
292
283
|
}
|
|
@@ -298,8 +289,8 @@ interface AssetBrowserProps {
|
|
|
298
289
|
onOpenScene?: (assetPath: string) => void;
|
|
299
290
|
}
|
|
300
291
|
|
|
301
|
-
type AssetRoot = 'public'
|
|
302
|
-
type AssetViewMode = 'grid' | 'list'
|
|
292
|
+
type AssetRoot = 'public';
|
|
293
|
+
type AssetViewMode = 'grid' | 'list';
|
|
303
294
|
type AssetSort = 'name' | 'type' | 'size' | 'health';
|
|
304
295
|
type AssetFilter =
|
|
305
296
|
| 'all'
|
|
@@ -309,44 +300,15 @@ type AssetFilter =
|
|
|
309
300
|
| 'imported'
|
|
310
301
|
| 'generated';
|
|
311
302
|
|
|
312
|
-
type BrowserEntry =
|
|
313
|
-
| {
|
|
314
|
-
source: 'root';
|
|
315
|
-
key: string;
|
|
316
|
-
name: string;
|
|
317
|
-
root: AssetRoot;
|
|
318
|
-
detail: string;
|
|
319
|
-
}
|
|
320
|
-
| { source: 'asset'; key: string; name: string; entry: AssetEntry }
|
|
321
|
-
| { source: 'data'; key: string; name: string; path: string }
|
|
322
|
-
| {
|
|
323
|
-
source: 'story';
|
|
324
|
-
key: string;
|
|
325
|
-
name: string;
|
|
326
|
-
modulePath: string;
|
|
327
|
-
storyName: string;
|
|
328
|
-
};
|
|
303
|
+
type BrowserEntry = { source: 'asset'; key: string; name: string; entry: AssetEntry };
|
|
329
304
|
|
|
330
305
|
function browserEntryKind(item: BrowserEntry): BrowserKind {
|
|
331
|
-
if (item.source === 'root') return 'folder';
|
|
332
|
-
if (item.source === 'data') return 'data';
|
|
333
|
-
if (item.source === 'story') return 'story';
|
|
334
306
|
return getAssetKind(item.entry);
|
|
335
307
|
}
|
|
336
308
|
|
|
337
|
-
function
|
|
338
|
-
if (root === 'data') return 'src/data';
|
|
339
|
-
return root;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function parentAssetLocation(
|
|
343
|
-
root: AssetRoot | null,
|
|
344
|
-
path: string,
|
|
345
|
-
): readonly [AssetRoot | null, string] {
|
|
346
|
-
if (root === null) return [null, ''];
|
|
309
|
+
function parentAssetPath(path: string): string {
|
|
347
310
|
const separator = path.lastIndexOf('/');
|
|
348
|
-
|
|
349
|
-
return [root, path.slice(0, separator)];
|
|
311
|
+
return separator < 0 ? '' : path.slice(0, separator);
|
|
350
312
|
}
|
|
351
313
|
|
|
352
314
|
function openProjectAssetEntry(
|
|
@@ -354,7 +316,7 @@ function openProjectAssetEntry(
|
|
|
354
316
|
options: {
|
|
355
317
|
currentRoot: AssetRoot;
|
|
356
318
|
currentPath: string;
|
|
357
|
-
navigate: (root: AssetRoot
|
|
319
|
+
navigate: (root: AssetRoot, path: string) => void;
|
|
358
320
|
onOpenScene?: (assetPath: string) => void;
|
|
359
321
|
openAsset: (url: string, kind: ViewableAssetKind) => void;
|
|
360
322
|
},
|
|
@@ -374,9 +336,6 @@ function openProjectAssetEntry(
|
|
|
374
336
|
}
|
|
375
337
|
|
|
376
338
|
function browserEntryDetail(item: BrowserEntry): string {
|
|
377
|
-
if (item.source === 'root') return item.detail;
|
|
378
|
-
if (item.source === 'story') return item.modulePath;
|
|
379
|
-
if (item.source === 'data') return item.path;
|
|
380
339
|
return item.entry.type === 'file' ? formatSize(item.entry.size) : 'Folder';
|
|
381
340
|
}
|
|
382
341
|
|
|
@@ -385,6 +344,42 @@ function browserEntryTitle(item: BrowserEntry): string {
|
|
|
385
344
|
return detail ? `${item.name}\n${detail}` : item.name;
|
|
386
345
|
}
|
|
387
346
|
|
|
347
|
+
function matchesAssetFilter(item: BrowserEntry, filter: AssetFilter, currentPath: string): boolean {
|
|
348
|
+
if (filter === 'all' || item.entry.type === 'directory') return true;
|
|
349
|
+
const capability = assetCapabilities(item.name);
|
|
350
|
+
const path = currentPath ? `${currentPath}/${item.name}` : item.name;
|
|
351
|
+
const predicates: Record<Exclude<AssetFilter, 'all'>, boolean> = {
|
|
352
|
+
'runtime-ready': capability.runtimeReady,
|
|
353
|
+
'source-only': capability.importable && !capability.runtimeReady,
|
|
354
|
+
unsupported: !capability.importable,
|
|
355
|
+
imported: path.startsWith('asset-library/local/'),
|
|
356
|
+
generated: path.startsWith('.generated/'),
|
|
357
|
+
};
|
|
358
|
+
return predicates[filter];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function compareAssetEntries(
|
|
362
|
+
left: BrowserEntry,
|
|
363
|
+
right: BrowserEntry,
|
|
364
|
+
sort: AssetSort,
|
|
365
|
+
currentPath: string,
|
|
366
|
+
): number {
|
|
367
|
+
if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
|
|
368
|
+
if (sort === 'size' && left.entry.size !== right.entry.size)
|
|
369
|
+
return right.entry.size - left.entry.size;
|
|
370
|
+
if (sort === 'type') {
|
|
371
|
+
const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
|
|
372
|
+
if (order !== 0) return order;
|
|
373
|
+
}
|
|
374
|
+
if (sort === 'health') {
|
|
375
|
+
const order = projectAssetHealth(left.name, currentPath).localeCompare(
|
|
376
|
+
projectAssetHealth(right.name, currentPath),
|
|
377
|
+
);
|
|
378
|
+
if (order !== 0) return order;
|
|
379
|
+
}
|
|
380
|
+
return left.name.localeCompare(right.name);
|
|
381
|
+
}
|
|
382
|
+
|
|
388
383
|
const GRID_ENTRY_STYLE: React.CSSProperties = {
|
|
389
384
|
width: '100%',
|
|
390
385
|
minWidth: 0,
|
|
@@ -429,10 +424,10 @@ function BrowserEntryVisual({
|
|
|
429
424
|
/>
|
|
430
425
|
);
|
|
431
426
|
}
|
|
432
|
-
if (kind === 'model' ||
|
|
427
|
+
if (kind === 'model' || modelThumbnailFormat(assetUrl)) {
|
|
433
428
|
return <ModelThumbnail url={assetUrl} />;
|
|
434
429
|
}
|
|
435
|
-
if (['audio', 'material', 'scene', 'prefab', 'json'
|
|
430
|
+
if (['audio', 'material', 'scene', 'prefab', 'json'].includes(kind)) {
|
|
436
431
|
return <TypedAssetThumbnail kind={kind} name={item.name} />;
|
|
437
432
|
}
|
|
438
433
|
return <AssetIcon kind={kind} />;
|
|
@@ -505,7 +500,7 @@ function BrowserEntryCard({
|
|
|
505
500
|
}: {
|
|
506
501
|
item: BrowserEntry;
|
|
507
502
|
selected: boolean;
|
|
508
|
-
currentRoot: AssetRoot
|
|
503
|
+
currentRoot: AssetRoot;
|
|
509
504
|
currentPath: string;
|
|
510
505
|
onDragStart: (event: React.DragEvent, item: BrowserEntry) => void;
|
|
511
506
|
onSelect: (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => void;
|
|
@@ -514,9 +509,9 @@ function BrowserEntryCard({
|
|
|
514
509
|
viewMode: AssetViewMode;
|
|
515
510
|
}) {
|
|
516
511
|
const kind = browserEntryKind(item);
|
|
517
|
-
const assetEntry = item.
|
|
518
|
-
const capability = assetEntry
|
|
519
|
-
const assetUrl = assetServingUrl(currentRoot
|
|
512
|
+
const assetEntry = item.entry;
|
|
513
|
+
const capability = assetEntry.type === 'file' ? assetCapabilities(assetEntry.name) : null;
|
|
514
|
+
const assetUrl = assetServingUrl(currentRoot, currentPath, item.name);
|
|
520
515
|
const title = browserEntryTitle(item);
|
|
521
516
|
const cursor = assetEntry
|
|
522
517
|
? assetEntry.type === 'directory' || capability?.placeable
|
|
@@ -530,9 +525,9 @@ function BrowserEntryCard({
|
|
|
530
525
|
|
|
531
526
|
return (
|
|
532
527
|
<div
|
|
533
|
-
className="vgai-selectable"
|
|
528
|
+
className="vgai-selectable vgai-project-asset-entry"
|
|
534
529
|
data-selected={selected || undefined}
|
|
535
|
-
data-
|
|
530
|
+
data-view={viewMode}
|
|
536
531
|
role="option"
|
|
537
532
|
aria-selected={selected}
|
|
538
533
|
tabIndex={0}
|
|
@@ -553,14 +548,15 @@ function BrowserEntryCard({
|
|
|
553
548
|
...(grid ? GRID_ENTRY_STYLE : LIST_ENTRY_STYLE),
|
|
554
549
|
borderRadius: radius.sm,
|
|
555
550
|
cursor,
|
|
556
|
-
border: selected ? `1px solid ${accent}` : '1px solid transparent',
|
|
557
551
|
userSelect: 'none',
|
|
558
552
|
}}
|
|
559
553
|
>
|
|
560
554
|
<div
|
|
555
|
+
className="vgai-project-asset-entry__preview"
|
|
556
|
+
data-testid="asset-entry-preview"
|
|
561
557
|
style={{
|
|
562
558
|
...(grid
|
|
563
|
-
? { width:
|
|
559
|
+
? { width: '100%', aspectRatio: '4 / 3', margin: '0 auto' }
|
|
564
560
|
: { width: 18, height: 18, margin: 0 }),
|
|
565
561
|
display: 'flex',
|
|
566
562
|
alignItems: 'center',
|
|
@@ -624,7 +620,6 @@ function BrowserEntryCard({
|
|
|
624
620
|
}
|
|
625
621
|
|
|
626
622
|
interface AssetBrowserToolbarProps {
|
|
627
|
-
currentRoot: AssetRoot | null;
|
|
628
623
|
currentPath: string;
|
|
629
624
|
searchOpen: boolean;
|
|
630
625
|
searchQuery: string;
|
|
@@ -632,7 +627,7 @@ interface AssetBrowserToolbarProps {
|
|
|
632
627
|
sort: AssetSort;
|
|
633
628
|
filter: AssetFilter;
|
|
634
629
|
density: number;
|
|
635
|
-
onNavigate: (root: AssetRoot
|
|
630
|
+
onNavigate: (root: AssetRoot, path: string) => void;
|
|
636
631
|
onSearchOpenChange: (open: boolean) => void;
|
|
637
632
|
onSearchQueryChange: (query: string) => void;
|
|
638
633
|
onViewModeChange: (mode: AssetViewMode) => void;
|
|
@@ -647,7 +642,6 @@ interface AssetBrowserToolbarProps {
|
|
|
647
642
|
}
|
|
648
643
|
|
|
649
644
|
function AssetBrowserToolbar({
|
|
650
|
-
currentRoot,
|
|
651
645
|
currentPath,
|
|
652
646
|
searchOpen,
|
|
653
647
|
searchQuery,
|
|
@@ -668,335 +662,254 @@ function AssetBrowserToolbar({
|
|
|
668
662
|
deleteDisabled,
|
|
669
663
|
onRefresh,
|
|
670
664
|
}: AssetBrowserToolbarProps) {
|
|
665
|
+
const actionsRef = useRef<HTMLDivElement>(null);
|
|
666
|
+
const [actionsOpen, setActionsOpen] = useState(false);
|
|
671
667
|
const breadcrumbSegments = currentPath ? currentPath.split('/') : [];
|
|
672
|
-
const
|
|
668
|
+
const parentPath = parentAssetPath(currentPath);
|
|
673
669
|
const closeSearch = () => {
|
|
674
670
|
onSearchQueryChange('');
|
|
675
671
|
onSearchOpenChange(false);
|
|
676
672
|
};
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
if (!actionsOpen) return;
|
|
675
|
+
const close = (event: PointerEvent) => {
|
|
676
|
+
if (!actionsRef.current?.contains(event.target as Node)) setActionsOpen(false);
|
|
677
|
+
};
|
|
678
|
+
document.addEventListener('pointerdown', close);
|
|
679
|
+
return () => document.removeEventListener('pointerdown', close);
|
|
680
|
+
}, [actionsOpen]);
|
|
677
681
|
|
|
678
682
|
return (
|
|
679
|
-
<
|
|
680
|
-
label="Asset
|
|
681
|
-
|
|
682
|
-
style={{
|
|
683
|
-
gap: 6,
|
|
684
|
-
flexWrap: 'wrap',
|
|
685
|
-
flexShrink: 0,
|
|
686
|
-
}}
|
|
687
|
-
>
|
|
688
|
-
<Tooltip text="Back" position="top">
|
|
689
|
-
<IconButton
|
|
690
|
-
data-testid="asset-back"
|
|
691
|
-
aria-label="Back"
|
|
692
|
-
disabled={currentRoot === null}
|
|
693
|
-
onClick={() => onNavigate(parentRoot, parentPath)}
|
|
694
|
-
>
|
|
695
|
-
<EditorIcon icon={faArrowLeft} />
|
|
696
|
-
</IconButton>
|
|
697
|
-
</Tooltip>
|
|
698
|
-
{searchOpen ? (
|
|
699
|
-
<TextInput
|
|
700
|
-
type="text"
|
|
701
|
-
data-testid="asset-search-input"
|
|
702
|
-
aria-label="Search assets"
|
|
703
|
-
placeholder="Search assets..."
|
|
704
|
-
value={searchQuery}
|
|
705
|
-
onChange={(event) => onSearchQueryChange(event.target.value)}
|
|
706
|
-
onKeyDown={(event) => {
|
|
707
|
-
if (event.key === 'Escape') closeSearch();
|
|
708
|
-
}}
|
|
709
|
-
className="vgai-input"
|
|
710
|
-
autoFocus
|
|
711
|
-
style={{ flex: 1, minWidth: 60 }}
|
|
712
|
-
/>
|
|
713
|
-
) : currentRoot !== null ? (
|
|
714
|
-
<nav
|
|
715
|
-
data-testid="asset-breadcrumb"
|
|
716
|
-
aria-label="Asset location"
|
|
717
|
-
style={{
|
|
718
|
-
flex: 1,
|
|
719
|
-
display: 'flex',
|
|
720
|
-
alignItems: 'center',
|
|
721
|
-
gap: 2,
|
|
722
|
-
fontSize: 12,
|
|
723
|
-
minWidth: 0,
|
|
724
|
-
overflow: 'hidden',
|
|
725
|
-
whiteSpace: 'nowrap',
|
|
726
|
-
}}
|
|
727
|
-
>
|
|
728
|
-
{currentPath ? (
|
|
729
|
-
<Button
|
|
730
|
-
type="button"
|
|
731
|
-
variant="ghost"
|
|
732
|
-
size="compact"
|
|
733
|
-
onClick={() => onNavigate(currentRoot, '')}
|
|
734
|
-
style={{ flexShrink: 0 }}
|
|
735
|
-
>
|
|
736
|
-
{assetRootLabel(currentRoot)}
|
|
737
|
-
</Button>
|
|
738
|
-
) : (
|
|
739
|
-
<span aria-current="location" style={{ color: text[1] }}>
|
|
740
|
-
{assetRootLabel(currentRoot)}
|
|
741
|
-
</span>
|
|
742
|
-
)}
|
|
743
|
-
{breadcrumbSegments.map((segment, index) => {
|
|
744
|
-
const path = breadcrumbSegments.slice(0, index + 1).join('/');
|
|
745
|
-
const isLast = index === breadcrumbSegments.length - 1;
|
|
746
|
-
return (
|
|
747
|
-
<span
|
|
748
|
-
key={path}
|
|
749
|
-
style={{
|
|
750
|
-
display: 'flex',
|
|
751
|
-
alignItems: 'center',
|
|
752
|
-
gap: 2,
|
|
753
|
-
minWidth: isLast ? 0 : undefined,
|
|
754
|
-
flexShrink: isLast ? 1 : 0,
|
|
755
|
-
}}
|
|
756
|
-
>
|
|
757
|
-
<span style={{ color: text[3] }}>/</span>
|
|
758
|
-
{isLast ? (
|
|
759
|
-
<span
|
|
760
|
-
aria-current="location"
|
|
761
|
-
title={segment}
|
|
762
|
-
style={{
|
|
763
|
-
color: text[1],
|
|
764
|
-
overflow: 'hidden',
|
|
765
|
-
textOverflow: 'ellipsis',
|
|
766
|
-
}}
|
|
767
|
-
>
|
|
768
|
-
{segment}
|
|
769
|
-
</span>
|
|
770
|
-
) : (
|
|
771
|
-
<Button
|
|
772
|
-
type="button"
|
|
773
|
-
variant="ghost"
|
|
774
|
-
size="compact"
|
|
775
|
-
onClick={() => onNavigate(currentRoot, path)}
|
|
776
|
-
>
|
|
777
|
-
{segment}
|
|
778
|
-
</Button>
|
|
779
|
-
)}
|
|
780
|
-
</span>
|
|
781
|
-
);
|
|
782
|
-
})}
|
|
783
|
-
</nav>
|
|
784
|
-
) : (
|
|
785
|
-
<div style={{ flex: 1 }} />
|
|
786
|
-
)}
|
|
787
|
-
|
|
788
|
-
<Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
|
|
789
|
-
<IconButton
|
|
790
|
-
data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
|
|
791
|
-
aria-label={searchOpen ? 'Close search' : 'Search assets'}
|
|
792
|
-
onClick={() => {
|
|
793
|
-
if (searchOpen) closeSearch();
|
|
794
|
-
else onSearchOpenChange(true);
|
|
795
|
-
}}
|
|
796
|
-
>
|
|
797
|
-
<EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
|
|
798
|
-
</IconButton>
|
|
799
|
-
</Tooltip>
|
|
800
|
-
<ToolbarGroup>
|
|
801
|
-
<Tooltip text="Grid view" position="top">
|
|
683
|
+
<div className="vgai-project-assets-toolbar">
|
|
684
|
+
<EditorToolbar label="Asset navigation" compact className="vgai-project-assets-toolbar__row">
|
|
685
|
+
<Tooltip text="Back" position="top">
|
|
802
686
|
<IconButton
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
onClick={() =>
|
|
687
|
+
data-testid="asset-back"
|
|
688
|
+
aria-label="Back"
|
|
689
|
+
disabled={!currentPath}
|
|
690
|
+
onClick={() => onNavigate('public', parentPath)}
|
|
807
691
|
>
|
|
808
|
-
<EditorIcon icon={
|
|
692
|
+
<EditorIcon icon={faArrowLeft} />
|
|
809
693
|
</IconButton>
|
|
810
694
|
</Tooltip>
|
|
811
|
-
|
|
812
|
-
<
|
|
813
|
-
|
|
814
|
-
data-testid="asset-
|
|
815
|
-
aria-
|
|
816
|
-
|
|
695
|
+
{searchOpen ? (
|
|
696
|
+
<TextInput
|
|
697
|
+
type="text"
|
|
698
|
+
data-testid="asset-search-input"
|
|
699
|
+
aria-label="Search assets"
|
|
700
|
+
placeholder="Search assets..."
|
|
701
|
+
value={searchQuery}
|
|
702
|
+
onChange={(event) => onSearchQueryChange(event.target.value)}
|
|
703
|
+
onKeyDown={(event) => {
|
|
704
|
+
if (event.key === 'Escape') closeSearch();
|
|
705
|
+
}}
|
|
706
|
+
className="vgai-input"
|
|
707
|
+
autoFocus
|
|
708
|
+
style={{ flex: 1, minWidth: 60 }}
|
|
709
|
+
/>
|
|
710
|
+
) : (
|
|
711
|
+
<nav
|
|
712
|
+
data-testid="asset-breadcrumb"
|
|
713
|
+
aria-label="Asset location"
|
|
714
|
+
style={{
|
|
715
|
+
flex: 1,
|
|
716
|
+
display: 'flex',
|
|
717
|
+
alignItems: 'center',
|
|
718
|
+
gap: 2,
|
|
719
|
+
fontSize: 12,
|
|
720
|
+
minWidth: 0,
|
|
721
|
+
overflow: 'hidden',
|
|
722
|
+
whiteSpace: 'nowrap',
|
|
723
|
+
}}
|
|
817
724
|
>
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
725
|
+
{currentPath ? (
|
|
726
|
+
<Button
|
|
727
|
+
type="button"
|
|
728
|
+
variant="ghost"
|
|
729
|
+
size="compact"
|
|
730
|
+
onClick={() => onNavigate('public', '')}
|
|
731
|
+
style={{ flexShrink: 0 }}
|
|
732
|
+
>
|
|
733
|
+
Assets
|
|
734
|
+
</Button>
|
|
735
|
+
) : (
|
|
736
|
+
<span aria-current="location" style={{ color: text[1] }}>
|
|
737
|
+
Assets
|
|
738
|
+
</span>
|
|
739
|
+
)}
|
|
740
|
+
{breadcrumbSegments.map((segment, index) => {
|
|
741
|
+
const path = breadcrumbSegments.slice(0, index + 1).join('/');
|
|
742
|
+
const isLast = index === breadcrumbSegments.length - 1;
|
|
743
|
+
return (
|
|
744
|
+
<span
|
|
745
|
+
key={path}
|
|
746
|
+
style={{
|
|
747
|
+
display: 'flex',
|
|
748
|
+
alignItems: 'center',
|
|
749
|
+
gap: 2,
|
|
750
|
+
minWidth: isLast ? 0 : undefined,
|
|
751
|
+
flexShrink: isLast ? 1 : 0,
|
|
752
|
+
}}
|
|
753
|
+
>
|
|
754
|
+
<span style={{ color: text[3] }}>/</span>
|
|
755
|
+
{isLast ? (
|
|
756
|
+
<span
|
|
757
|
+
aria-current="location"
|
|
758
|
+
title={segment}
|
|
759
|
+
style={{
|
|
760
|
+
color: text[1],
|
|
761
|
+
overflow: 'hidden',
|
|
762
|
+
textOverflow: 'ellipsis',
|
|
763
|
+
}}
|
|
764
|
+
>
|
|
765
|
+
{segment}
|
|
766
|
+
</span>
|
|
767
|
+
) : (
|
|
768
|
+
<Button
|
|
769
|
+
type="button"
|
|
770
|
+
variant="ghost"
|
|
771
|
+
size="compact"
|
|
772
|
+
onClick={() => onNavigate('public', path)}
|
|
773
|
+
>
|
|
774
|
+
{segment}
|
|
775
|
+
</Button>
|
|
776
|
+
)}
|
|
777
|
+
</span>
|
|
778
|
+
);
|
|
779
|
+
})}
|
|
780
|
+
</nav>
|
|
781
|
+
)}
|
|
782
|
+
|
|
783
|
+
<Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
|
|
822
784
|
<IconButton
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
785
|
+
data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
|
|
786
|
+
aria-label={searchOpen ? 'Close search' : 'Search assets'}
|
|
787
|
+
onClick={() => {
|
|
788
|
+
if (searchOpen) closeSearch();
|
|
789
|
+
else onSearchOpenChange(true);
|
|
790
|
+
}}
|
|
827
791
|
>
|
|
828
|
-
<EditorIcon icon={
|
|
792
|
+
<EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
|
|
829
793
|
</IconButton>
|
|
830
794
|
</Tooltip>
|
|
831
|
-
</
|
|
832
|
-
<
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
onChange={(event) => onSortChange(event.target.value as AssetSort)}
|
|
795
|
+
</EditorToolbar>
|
|
796
|
+
<EditorToolbar
|
|
797
|
+
label="Asset browser controls"
|
|
798
|
+
compact
|
|
799
|
+
className="vgai-project-assets-toolbar__row"
|
|
837
800
|
>
|
|
838
|
-
<
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
)}
|
|
866
|
-
<Button
|
|
867
|
-
data-command-id={PROJECT_ASSET_COMMANDS.import.id}
|
|
868
|
-
variant="ghost"
|
|
869
|
-
size="comfortable"
|
|
870
|
-
onClick={onImport}
|
|
871
|
-
>
|
|
872
|
-
Import
|
|
873
|
-
</Button>
|
|
874
|
-
<Button
|
|
875
|
-
data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
|
|
876
|
-
variant="ghost"
|
|
877
|
-
size="comfortable"
|
|
878
|
-
onClick={onCreateFolder}
|
|
879
|
-
>
|
|
880
|
-
New folder
|
|
881
|
-
</Button>
|
|
882
|
-
<IconButton
|
|
883
|
-
data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
|
|
884
|
-
aria-label="Delete selected assets"
|
|
885
|
-
disabled={deleteDisabled}
|
|
886
|
-
onClick={onDelete}
|
|
887
|
-
>
|
|
888
|
-
<EditorIcon icon={faTrash} />
|
|
889
|
-
</IconButton>
|
|
890
|
-
<Tooltip text="Refresh" position="top">
|
|
891
|
-
<IconButton
|
|
892
|
-
aria-label="Refresh assets"
|
|
893
|
-
onClick={(event) => {
|
|
894
|
-
event.stopPropagation();
|
|
895
|
-
onRefresh();
|
|
896
|
-
}}
|
|
801
|
+
<ToolbarGroup>
|
|
802
|
+
<Tooltip text="Grid view" position="top">
|
|
803
|
+
<IconButton
|
|
804
|
+
aria-label="Grid view"
|
|
805
|
+
data-testid="asset-view-grid"
|
|
806
|
+
aria-pressed={viewMode === 'grid'}
|
|
807
|
+
onClick={() => onViewModeChange('grid')}
|
|
808
|
+
>
|
|
809
|
+
<EditorIcon icon={faTableCellsLarge} />
|
|
810
|
+
</IconButton>
|
|
811
|
+
</Tooltip>
|
|
812
|
+
<Tooltip text="List view" position="top">
|
|
813
|
+
<IconButton
|
|
814
|
+
aria-label="List view"
|
|
815
|
+
data-testid="asset-view-list"
|
|
816
|
+
aria-pressed={viewMode === 'list'}
|
|
817
|
+
onClick={() => onViewModeChange('list')}
|
|
818
|
+
>
|
|
819
|
+
<EditorIcon icon={faList} />
|
|
820
|
+
</IconButton>
|
|
821
|
+
</Tooltip>
|
|
822
|
+
</ToolbarGroup>
|
|
823
|
+
<Select
|
|
824
|
+
className="vgai-select vgai-project-assets-toolbar__select"
|
|
825
|
+
aria-label="Sort project assets"
|
|
826
|
+
value={sort}
|
|
827
|
+
onChange={(event) => onSortChange(event.target.value as AssetSort)}
|
|
897
828
|
>
|
|
898
|
-
<
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
key,
|
|
922
|
-
entries.filter((entry) => entry.type === 'directory').map((entry) => entry.name),
|
|
923
|
-
);
|
|
924
|
-
return next;
|
|
925
|
-
});
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
}, [folders, props.expanded]);
|
|
929
|
-
|
|
930
|
-
const renderFolder = (path: string, depth: number): React.ReactNode => {
|
|
931
|
-
const key = path ? `public/${path}` : 'public';
|
|
932
|
-
const open = props.expanded.has(key);
|
|
933
|
-
return (
|
|
934
|
-
<div key={key}>
|
|
935
|
-
<div className="vgai-asset-tree-row" style={{ paddingLeft: 6 + depth * 14 }}>
|
|
936
|
-
<Button
|
|
937
|
-
type="button"
|
|
938
|
-
className="vgai-asset-tree-toggle"
|
|
939
|
-
aria-label={`${open ? 'Collapse' : 'Expand'} ${path.split('/').pop() || 'public'}`}
|
|
940
|
-
aria-expanded={open}
|
|
941
|
-
onClick={() => props.onExpanded(key)}
|
|
942
|
-
>
|
|
943
|
-
<EditorIcon
|
|
944
|
-
icon={faChevronRight}
|
|
945
|
-
style={{ transform: open ? 'rotate(90deg)' : undefined }}
|
|
946
|
-
/>
|
|
947
|
-
</Button>
|
|
948
|
-
<Button
|
|
949
|
-
type="button"
|
|
950
|
-
className="vgai-asset-tree-target"
|
|
951
|
-
aria-current={
|
|
952
|
-
props.currentRoot === 'public' && props.currentPath === path ? 'page' : undefined
|
|
953
|
-
}
|
|
954
|
-
onClick={() => props.onNavigate('public', path)}
|
|
829
|
+
<option value="name">Name</option>
|
|
830
|
+
<option value="type">Type</option>
|
|
831
|
+
<option value="size">Size</option>
|
|
832
|
+
<option value="health">Health</option>
|
|
833
|
+
</Select>
|
|
834
|
+
<Select
|
|
835
|
+
className="vgai-select vgai-project-assets-toolbar__select"
|
|
836
|
+
aria-label="Filter project assets"
|
|
837
|
+
value={filter}
|
|
838
|
+
onChange={(event) => onFilterChange(event.target.value as AssetFilter)}
|
|
839
|
+
>
|
|
840
|
+
<option value="all">All</option>
|
|
841
|
+
<option value="runtime-ready">Runtime-ready</option>
|
|
842
|
+
<option value="source-only">Source-only</option>
|
|
843
|
+
<option value="unsupported">Unsupported</option>
|
|
844
|
+
<option value="imported">Imported</option>
|
|
845
|
+
<option value="generated">Generated</option>
|
|
846
|
+
</Select>
|
|
847
|
+
<Tooltip text="Import files" position="top">
|
|
848
|
+
<IconButton
|
|
849
|
+
data-command-id={PROJECT_ASSET_COMMANDS.import.id}
|
|
850
|
+
aria-label="Import files"
|
|
851
|
+
onClick={onImport}
|
|
955
852
|
>
|
|
956
|
-
<EditorIcon icon={
|
|
957
|
-
|
|
958
|
-
|
|
853
|
+
<EditorIcon icon={faBoxArchive} />
|
|
854
|
+
</IconButton>
|
|
855
|
+
</Tooltip>
|
|
856
|
+
<div ref={actionsRef} className="vgai-project-assets-toolbar__more">
|
|
857
|
+
<Tooltip text="More asset actions" position="top">
|
|
858
|
+
<IconButton
|
|
859
|
+
aria-label="More asset actions"
|
|
860
|
+
aria-expanded={actionsOpen}
|
|
861
|
+
aria-haspopup="menu"
|
|
862
|
+
onClick={() => setActionsOpen((value) => !value)}
|
|
863
|
+
>
|
|
864
|
+
<EditorIcon icon={faEllipsis} />
|
|
865
|
+
</IconButton>
|
|
866
|
+
</Tooltip>
|
|
867
|
+
{actionsOpen && (
|
|
868
|
+
<Menu
|
|
869
|
+
autoFocusFirst
|
|
870
|
+
className="vgai-project-assets-toolbar__menu"
|
|
871
|
+
onDismiss={() => setActionsOpen(false)}
|
|
872
|
+
>
|
|
873
|
+
<MenuItem
|
|
874
|
+
data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
|
|
875
|
+
onSelect={() => {
|
|
876
|
+
setActionsOpen(false);
|
|
877
|
+
onCreateFolder();
|
|
878
|
+
}}
|
|
879
|
+
>
|
|
880
|
+
New folder
|
|
881
|
+
</MenuItem>
|
|
882
|
+
<MenuItem
|
|
883
|
+
onSelect={() => {
|
|
884
|
+
setActionsOpen(false);
|
|
885
|
+
onDensityChange(density <= 96 ? 132 : 88);
|
|
886
|
+
}}
|
|
887
|
+
>
|
|
888
|
+
{density <= 96 ? 'Larger thumbnails' : 'Smaller thumbnails'}
|
|
889
|
+
</MenuItem>
|
|
890
|
+
<MenuItem
|
|
891
|
+
disabled={deleteDisabled}
|
|
892
|
+
data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
|
|
893
|
+
onSelect={() => {
|
|
894
|
+
setActionsOpen(false);
|
|
895
|
+
onDelete();
|
|
896
|
+
}}
|
|
897
|
+
>
|
|
898
|
+
Delete selected
|
|
899
|
+
</MenuItem>
|
|
900
|
+
<MenuItem
|
|
901
|
+
onSelect={() => {
|
|
902
|
+
setActionsOpen(false);
|
|
903
|
+
onRefresh();
|
|
904
|
+
}}
|
|
905
|
+
>
|
|
906
|
+
Refresh
|
|
907
|
+
</MenuItem>
|
|
908
|
+
</Menu>
|
|
909
|
+
)}
|
|
959
910
|
</div>
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
</div>
|
|
963
|
-
);
|
|
964
|
-
};
|
|
965
|
-
|
|
966
|
-
return (
|
|
967
|
-
<aside className="vgai-asset-tree" aria-label="Project asset sources">
|
|
968
|
-
<div className="vgai-asset-facet-title">Project</div>
|
|
969
|
-
{renderFolder('', 0)}
|
|
970
|
-
<Button
|
|
971
|
-
type="button"
|
|
972
|
-
className="vgai-asset-tree-row"
|
|
973
|
-
onClick={() => props.onNavigate('data', '')}
|
|
974
|
-
>
|
|
975
|
-
<EditorIcon icon={faTable} /> src/data
|
|
976
|
-
</Button>
|
|
977
|
-
<Button
|
|
978
|
-
type="button"
|
|
979
|
-
className="vgai-asset-tree-row"
|
|
980
|
-
onClick={() => props.onNavigate('stories', '')}
|
|
981
|
-
>
|
|
982
|
-
<EditorIcon icon={faBookOpen} /> stories
|
|
983
|
-
</Button>
|
|
984
|
-
<div className="vgai-asset-facet-title">References</div>
|
|
985
|
-
<Button
|
|
986
|
-
type="button"
|
|
987
|
-
className="vgai-asset-tree-row"
|
|
988
|
-
onClick={() => props.onNavigate('public', 'asset-library/local')}
|
|
989
|
-
>
|
|
990
|
-
Imported
|
|
991
|
-
</Button>
|
|
992
|
-
<Button
|
|
993
|
-
type="button"
|
|
994
|
-
className="vgai-asset-tree-row"
|
|
995
|
-
onClick={() => props.onNavigate('public', '.generated')}
|
|
996
|
-
>
|
|
997
|
-
Generated
|
|
998
|
-
</Button>
|
|
999
|
-
</aside>
|
|
911
|
+
</EditorToolbar>
|
|
912
|
+
</div>
|
|
1000
913
|
);
|
|
1001
914
|
}
|
|
1002
915
|
|
|
@@ -1004,19 +917,12 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1004
917
|
const store = useEditorStore();
|
|
1005
918
|
const history = useHistoryService();
|
|
1006
919
|
const initial = useMemo(readAssetBrowserState, []);
|
|
1007
|
-
const
|
|
1008
|
-
const storyModules = useSyncExternalStore(
|
|
1009
|
-
subscribeProjectStoryModules,
|
|
1010
|
-
getProjectStoryModules,
|
|
1011
|
-
getProjectStoryModules,
|
|
1012
|
-
);
|
|
1013
|
-
const [currentRoot, setCurrentRoot] = useState<AssetRoot | null>(initial.currentRoot);
|
|
920
|
+
const currentRoot: AssetRoot = 'public';
|
|
1014
921
|
const [currentPath, setCurrentPath] = useState(initial.currentPath);
|
|
1015
922
|
const [viewMode, setViewMode] = useState<AssetViewMode>(initial.viewMode);
|
|
1016
923
|
const [sort, setSort] = useState<AssetSort>(initial.sort);
|
|
1017
924
|
const [filter, setFilter] = useState<AssetFilter>(initial.filter);
|
|
1018
925
|
const [density, setDensity] = useState(initial.density);
|
|
1019
|
-
const [expanded, setExpanded] = useState(new Set(initial.expanded));
|
|
1020
926
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
1021
927
|
const [entries, setEntries] = useState<AssetEntry[]>([]);
|
|
1022
928
|
const [searchQuery, setSearchQuery] = useState('');
|
|
@@ -1035,16 +941,14 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1035
941
|
localStorage.setItem(
|
|
1036
942
|
projectAssetBrowserStateKey(projectId),
|
|
1037
943
|
JSON.stringify({
|
|
1038
|
-
currentRoot,
|
|
1039
944
|
currentPath,
|
|
1040
945
|
viewMode,
|
|
1041
946
|
sort,
|
|
1042
947
|
filter,
|
|
1043
948
|
density,
|
|
1044
|
-
expanded: [...expanded],
|
|
1045
949
|
} satisfies PersistedAssetBrowserState),
|
|
1046
950
|
);
|
|
1047
|
-
}, [currentPath,
|
|
951
|
+
}, [currentPath, density, filter, sort, viewMode]);
|
|
1048
952
|
|
|
1049
953
|
const fetchEntries = useCallback(async (root: AssetRoot, dir: string) => {
|
|
1050
954
|
const key = `${root}:${dir}`;
|
|
@@ -1063,11 +967,10 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1063
967
|
}, []);
|
|
1064
968
|
|
|
1065
969
|
useEffect(() => {
|
|
1066
|
-
|
|
970
|
+
fetchEntries(currentRoot, currentPath);
|
|
1067
971
|
}, [currentRoot, currentPath, fetchEntries]);
|
|
1068
972
|
|
|
1069
|
-
const navigate = useCallback((
|
|
1070
|
-
setCurrentRoot(root);
|
|
973
|
+
const navigate = useCallback((_root: AssetRoot, path: string) => {
|
|
1071
974
|
setCurrentPath(path);
|
|
1072
975
|
setSelectedFiles(new Set());
|
|
1073
976
|
setSelectionAnchor(null);
|
|
@@ -1076,101 +979,30 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1076
979
|
setSearchOpen(false);
|
|
1077
980
|
}, []);
|
|
1078
981
|
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
982
|
+
// Assets is a file browser over the project's actual public asset root.
|
|
983
|
+
// Data and React stories have their own semantic document/hierarchy entry
|
|
984
|
+
// points; presenting them here as synthetic folders made the browser lie
|
|
985
|
+
// about the filesystem and duplicated navigation.
|
|
1082
986
|
const browserEntries = useMemo<BrowserEntry[]>(() => {
|
|
1083
|
-
if (currentRoot === null) {
|
|
1084
|
-
return [
|
|
1085
|
-
{
|
|
1086
|
-
source: 'root',
|
|
1087
|
-
key: 'root:public',
|
|
1088
|
-
name: 'public',
|
|
1089
|
-
root: 'public',
|
|
1090
|
-
detail: 'Models, images, scenes, audio, and other game assets',
|
|
1091
|
-
},
|
|
1092
|
-
{
|
|
1093
|
-
source: 'root',
|
|
1094
|
-
key: 'root:data',
|
|
1095
|
-
name: 'src/data',
|
|
1096
|
-
root: 'data',
|
|
1097
|
-
detail: 'Typed game data',
|
|
1098
|
-
},
|
|
1099
|
-
{
|
|
1100
|
-
source: 'root',
|
|
1101
|
-
key: 'root:stories',
|
|
1102
|
-
name: 'stories',
|
|
1103
|
-
root: 'stories',
|
|
1104
|
-
detail: 'React component stories',
|
|
1105
|
-
},
|
|
1106
|
-
];
|
|
1107
|
-
}
|
|
1108
|
-
if (currentRoot === 'data') {
|
|
1109
|
-
return loadedDataFiles().map((file) => ({
|
|
1110
|
-
source: 'data',
|
|
1111
|
-
key: `data:${file.path}`,
|
|
1112
|
-
name: file.path.split('/').pop() ?? file.path,
|
|
1113
|
-
path: file.path,
|
|
1114
|
-
}));
|
|
1115
|
-
}
|
|
1116
|
-
if (currentRoot === 'stories') {
|
|
1117
|
-
return storyModules.flatMap((module_) =>
|
|
1118
|
-
module_.ok
|
|
1119
|
-
? module_.stories.map((story) => ({
|
|
1120
|
-
source: 'story' as const,
|
|
1121
|
-
key: `story:${module_.modulePath}:${story.name}`,
|
|
1122
|
-
name: story.name,
|
|
1123
|
-
modulePath: module_.modulePath,
|
|
1124
|
-
storyName: story.name,
|
|
1125
|
-
}))
|
|
1126
|
-
: [],
|
|
1127
|
-
);
|
|
1128
|
-
}
|
|
1129
987
|
return entries.map((entry) => ({
|
|
1130
988
|
source: 'asset' as const,
|
|
1131
989
|
key: `asset:${currentPath}:${entry.name}`,
|
|
1132
990
|
name: entry.name,
|
|
1133
991
|
entry,
|
|
1134
992
|
}));
|
|
1135
|
-
}, [currentPath,
|
|
993
|
+
}, [currentPath, entries]);
|
|
1136
994
|
const filtered = useMemo(() => {
|
|
1137
995
|
const query = searchQuery.toLowerCase();
|
|
1138
996
|
const visible = browserEntries.filter((item) => {
|
|
1139
997
|
if (query && !item.name.toLowerCase().includes(query)) return false;
|
|
1140
|
-
|
|
1141
|
-
return true;
|
|
1142
|
-
const capability = assetCapabilities(item.name);
|
|
1143
|
-
const path = currentPath ? `${currentPath}/${item.name}` : item.name;
|
|
1144
|
-
if (filter === 'runtime-ready') return capability.runtimeReady;
|
|
1145
|
-
if (filter === 'source-only') return capability.importable && !capability.runtimeReady;
|
|
1146
|
-
if (filter === 'unsupported') return !capability.importable;
|
|
1147
|
-
if (filter === 'imported') return path.startsWith('asset-library/local/');
|
|
1148
|
-
return path.startsWith('.generated/');
|
|
1149
|
-
});
|
|
1150
|
-
return [...visible].sort((left, right) => {
|
|
1151
|
-
if (left.source === 'asset' && right.source === 'asset') {
|
|
1152
|
-
if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
|
|
1153
|
-
if (sort === 'size' && left.entry.size !== right.entry.size)
|
|
1154
|
-
return right.entry.size - left.entry.size;
|
|
1155
|
-
if (sort === 'type') {
|
|
1156
|
-
const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
|
|
1157
|
-
if (order !== 0) return order;
|
|
1158
|
-
}
|
|
1159
|
-
if (sort === 'health') {
|
|
1160
|
-
const leftHealth = projectAssetHealth(left.name, currentPath);
|
|
1161
|
-
const rightHealth = projectAssetHealth(right.name, currentPath);
|
|
1162
|
-
const order = leftHealth.localeCompare(rightHealth);
|
|
1163
|
-
if (order !== 0) return order;
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
return left.name.localeCompare(right.name);
|
|
998
|
+
return matchesAssetFilter(item, filter, currentPath);
|
|
1167
999
|
});
|
|
1000
|
+
return [...visible].sort((left, right) => compareAssetEntries(left, right, sort, currentPath));
|
|
1168
1001
|
}, [browserEntries, currentPath, filter, searchQuery, sort]);
|
|
1169
1002
|
|
|
1170
1003
|
// --- Drag start ---
|
|
1171
1004
|
const onDragStart = useCallback(
|
|
1172
1005
|
(e: React.DragEvent, item: BrowserEntry) => {
|
|
1173
|
-
if (item.source !== 'asset' || currentRoot !== 'public') return;
|
|
1174
1006
|
const { entry } = item;
|
|
1175
1007
|
const capability = assetCapabilities(entry.name);
|
|
1176
1008
|
if (entry.type !== 'file' || !capability.placeable) {
|
|
@@ -1212,10 +1044,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1212
1044
|
setSelectionAnchor(item.key);
|
|
1213
1045
|
}
|
|
1214
1046
|
setSelectedFiles(next);
|
|
1215
|
-
if (item.source !== 'asset' || currentRoot !== 'public') {
|
|
1216
|
-
clearSelectedAsset();
|
|
1217
|
-
return;
|
|
1218
|
-
}
|
|
1219
1047
|
const { entry } = item;
|
|
1220
1048
|
if (entry.type === 'directory') {
|
|
1221
1049
|
clearSelectedAsset();
|
|
@@ -1269,20 +1097,8 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1269
1097
|
// workspace document (W2; was a right-rail asset tab pre-W2). ---
|
|
1270
1098
|
const onDoubleClick = useCallback(
|
|
1271
1099
|
(item: BrowserEntry) => {
|
|
1272
|
-
if (item.source === 'root') {
|
|
1273
|
-
navigate(item.root, '');
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
if (item.source === 'data') {
|
|
1277
|
-
openDataDocument(store, item.path);
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
if (item.source === 'story') {
|
|
1281
|
-
openStoryDocument(store, item.modulePath, item.storyName);
|
|
1282
|
-
return;
|
|
1283
|
-
}
|
|
1284
1100
|
openProjectAssetEntry(item.entry, {
|
|
1285
|
-
currentRoot
|
|
1101
|
+
currentRoot,
|
|
1286
1102
|
currentPath,
|
|
1287
1103
|
navigate,
|
|
1288
1104
|
...(onOpenScene ? { onOpenScene } : {}),
|
|
@@ -1294,20 +1110,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1294
1110
|
|
|
1295
1111
|
// --- Refresh ---
|
|
1296
1112
|
const onRefresh = useCallback(() => {
|
|
1297
|
-
if (currentRoot === null) {
|
|
1298
|
-
cacheRef.current.clear();
|
|
1299
|
-
void refreshDataFiles();
|
|
1300
|
-
void refreshProjectStories(getCurrentProject());
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
if (currentRoot === 'data') {
|
|
1304
|
-
void refreshDataFiles();
|
|
1305
|
-
return;
|
|
1306
|
-
}
|
|
1307
|
-
if (currentRoot === 'stories') {
|
|
1308
|
-
void refreshProjectStories(getCurrentProject());
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
1113
|
cacheRef.current.clear();
|
|
1312
1114
|
fetchEntries(currentRoot, currentPath);
|
|
1313
1115
|
}, [currentRoot, currentPath, fetchEntries]);
|
|
@@ -1340,9 +1142,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1340
1142
|
const selectedAssetPaths = useMemo(
|
|
1341
1143
|
() =>
|
|
1342
1144
|
selectedItems.flatMap((item) =>
|
|
1343
|
-
item.
|
|
1344
|
-
? [currentPath ? `${currentPath}/${item.name}` : item.name]
|
|
1345
|
-
: [],
|
|
1145
|
+
item.entry.type === 'file' ? [currentPath ? `${currentPath}/${item.name}` : item.name] : [],
|
|
1346
1146
|
),
|
|
1347
1147
|
[currentPath, selectedItems],
|
|
1348
1148
|
);
|
|
@@ -1402,7 +1202,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1402
1202
|
|
|
1403
1203
|
const renameItem = useCallback(
|
|
1404
1204
|
async (item: BrowserEntry) => {
|
|
1405
|
-
if (item.
|
|
1205
|
+
if (item.entry.type !== 'file') return;
|
|
1406
1206
|
const nextName = window.prompt('Rename asset', item.name);
|
|
1407
1207
|
if (!nextName || nextName === item.name) return;
|
|
1408
1208
|
const source = currentPath ? `${currentPath}/${item.name}` : item.name;
|
|
@@ -1414,7 +1214,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1414
1214
|
|
|
1415
1215
|
const duplicateItem = useCallback(
|
|
1416
1216
|
async (item: BrowserEntry) => {
|
|
1417
|
-
if (item.
|
|
1217
|
+
if (item.entry.type !== 'file') return;
|
|
1418
1218
|
const dot = item.name.lastIndexOf('.');
|
|
1419
1219
|
const copyName =
|
|
1420
1220
|
dot > 0 ? `${item.name.slice(0, dot)} copy${item.name.slice(dot)}` : `${item.name} copy`;
|
|
@@ -1425,15 +1225,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1425
1225
|
[currentPath, finishOperation],
|
|
1426
1226
|
);
|
|
1427
1227
|
|
|
1428
|
-
const toggleExpanded = useCallback((path: string) => {
|
|
1429
|
-
setExpanded((current) => {
|
|
1430
|
-
const next = new Set(current);
|
|
1431
|
-
if (next.has(path)) next.delete(path);
|
|
1432
|
-
else next.add(path);
|
|
1433
|
-
return next;
|
|
1434
|
-
});
|
|
1435
|
-
}, []);
|
|
1436
|
-
|
|
1437
1228
|
useEffect(() => {
|
|
1438
1229
|
const handler = (event: Event) => {
|
|
1439
1230
|
const id = (event as CustomEvent<{ id?: string }>).detail?.id;
|
|
@@ -1480,16 +1271,15 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1480
1271
|
event.preventDefault();
|
|
1481
1272
|
}
|
|
1482
1273
|
}}
|
|
1483
|
-
style={{
|
|
1274
|
+
style={{
|
|
1275
|
+
display: 'flex',
|
|
1276
|
+
flexDirection: 'column',
|
|
1277
|
+
flex: 1,
|
|
1278
|
+
minHeight: 0,
|
|
1279
|
+
position: 'relative',
|
|
1280
|
+
}}
|
|
1484
1281
|
>
|
|
1485
|
-
<
|
|
1486
|
-
currentRoot={currentRoot}
|
|
1487
|
-
currentPath={currentPath}
|
|
1488
|
-
expanded={expanded}
|
|
1489
|
-
onExpanded={toggleExpanded}
|
|
1490
|
-
onNavigate={navigate}
|
|
1491
|
-
/>
|
|
1492
|
-
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
1282
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
|
|
1493
1283
|
<TextInput
|
|
1494
1284
|
ref={importInputRef}
|
|
1495
1285
|
type="file"
|
|
@@ -1502,7 +1292,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1502
1292
|
}}
|
|
1503
1293
|
/>
|
|
1504
1294
|
<AssetBrowserToolbar
|
|
1505
|
-
currentRoot={currentRoot}
|
|
1506
1295
|
currentPath={currentPath}
|
|
1507
1296
|
searchOpen={searchOpen}
|
|
1508
1297
|
searchQuery={searchQuery}
|
|
@@ -1526,6 +1315,8 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
|
|
|
1526
1315
|
|
|
1527
1316
|
{/* One file-browser surface; grid/list are presentation modes, not navigation. */}
|
|
1528
1317
|
<div
|
|
1318
|
+
className="vgai-project-assets-results"
|
|
1319
|
+
data-view={viewMode}
|
|
1529
1320
|
style={{
|
|
1530
1321
|
flex: 1,
|
|
1531
1322
|
overflowY: 'auto',
|