@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.
Files changed (47) hide show
  1. package/dist/assets/{axe-C7YBtOuM.js → axe-BJjvBeOZ.js} +1 -1
  2. package/dist/assets/{browserAll-DDtGGE48.js → browserAll-Dn0pgD87.js} +1 -1
  3. package/dist/assets/{create-runtime-DbTlBlKG.js → create-runtime-BF9OD-U8.js} +1 -1
  4. package/dist/assets/{game-Dch9sAPc.js → game-B346oj_f.js} +1 -1
  5. package/dist/assets/{game-BE0r7DMI.js → game-BCNN63uV.js} +1 -1
  6. package/dist/assets/{game-SAFn90pA.js → game-BR0GBNgF.js} +1 -1
  7. package/dist/assets/{game-Cqzj_lpD.js → game-CLNm2g0y.js} +1 -1
  8. package/dist/assets/{game-CcHfYxjP.js → game-DQxhCYsZ.js} +1 -1
  9. package/dist/assets/{game-BdTHR2_X.js → game-DeTGdgYt.js} +1 -1
  10. package/dist/assets/{game-loop-DMIgSMV-.js → game-loop-t5W_oqxz.js} +1 -1
  11. package/dist/assets/{game-source-Dk83w26t.js → game-source-DzE9taML.js} +1 -1
  12. package/dist/assets/{game-DSZdeCHu.js → game-u6c2Patu.js} +1 -1
  13. package/dist/assets/{game-C3iQGSqo.js → game-umrHIqFB.js} +1 -1
  14. package/dist/assets/{index-C-LhOTBN.js → index-CgcH1RpF.js} +1 -1
  15. package/dist/assets/{index-CDkxceqE.js → index-D3tcNB2T.js} +1 -1
  16. package/dist/assets/{index-CQEfi217.js → index-DbQzGLxv.js} +771 -771
  17. package/dist/assets/{index-4idIsHHJ.js → index-Fc8Ezr20.js} +1 -1
  18. package/dist/assets/index-bAGhEnnO.css +1 -0
  19. package/dist/assets/{ingest-siblings-s0P5I1Ub.js → ingest-siblings-CW_xbvrF.js} +1 -1
  20. package/dist/assets/{mount-manifest-DXMOlF79.js → mount-manifest-DgUscyaD.js} +1 -1
  21. package/dist/assets/{react-18-C9eHn8x1.js → react-18-C4tVCii_.js} +1 -1
  22. package/dist/assets/webworkerAll-BA4aq7xq.js +1 -0
  23. package/dist/assets/{world-documents-GM0a9Kv7.js → world-documents-BSaA5YIF.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +21 -1
  26. package/package.json +3 -3
  27. package/server/packaged.ts +20 -0
  28. package/src/asset-editor-context.tsx +130 -0
  29. package/src/asset-preview.ts +4 -0
  30. package/src/asset-selection.ts +9 -4
  31. package/src/asset-workflow/thumbnail-system.ts +16 -7
  32. package/src/components/AssetBrowser.tsx +330 -539
  33. package/src/components/AssetEditorShell.tsx +42 -112
  34. package/src/components/AssetImportDetails.tsx +24 -6
  35. package/src/components/GameHierarchy.tsx +26 -1
  36. package/src/components/Inspector.tsx +38 -2
  37. package/src/components/OnlineAssetBrowser.tsx +295 -343
  38. package/src/components/WorkspaceDock.tsx +6 -12
  39. package/src/components/asset-documents.tsx +41 -12
  40. package/src/components/asset-selection-section.tsx +34 -1
  41. package/src/components/asset-viewers/ModelViewer.tsx +7 -4
  42. package/src/components/asset-viewers/OnlineAssetDetail.tsx +233 -205
  43. package/src/components/asset-workflow.css +197 -264
  44. package/src/editor-api.ts +1 -1
  45. package/template/vgai.game.json +1 -1
  46. package/dist/assets/index-Dvi-V0NE.css +0 -1
  47. package/dist/assets/webworkerAll-Fn0e-ouF.js +0 -1
@@ -1,12 +1,13 @@
1
- import { faCheck, faCloud, faSpinner } from '@fortawesome/free-solid-svg-icons';
1
+ import { faSpinner } from '@fortawesome/free-solid-svg-icons';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
3
4
  import {
4
5
  type AssetLibrarySort,
5
6
  assetLibraryStateKey,
6
7
  onlineAssetKey,
7
8
  queryAssetLibrary,
8
9
  } from '../asset-workflow/asset-library-query';
9
- import { Button, Checkbox, EditorIcon, Select, TextInput, text } from '../design-system';
10
+ import { Button, Checkbox, EditorIcon, Select, StateSurface, TextInput } from '../design-system';
10
11
  import { useEditorStore } from '../EditorContext';
11
12
  import {
12
13
  getAssetLibraryCategories,
@@ -18,11 +19,9 @@ import {
18
19
  } from '../editor-api';
19
20
  import { getCurrentProject } from '../project-manager';
20
21
  import { openOnlineAssetDocument } from './asset-documents';
21
- import { OnlineAssetDetail } from './asset-viewers/OnlineAssetDetail';
22
22
  import './asset-workflow.css';
23
23
 
24
24
  type Source = 'local' | 'polyhaven' | 'ambientcg';
25
- type Density = 'compact' | 'standard' | 'large';
26
25
  type Sort = AssetLibrarySort;
27
26
 
28
27
  interface SourceOption {
@@ -66,30 +65,20 @@ interface PersistedLibraryState {
66
65
  source: Source;
67
66
  assetType: string;
68
67
  query: string;
69
- density: Density;
70
68
  sort: Sort;
71
69
  favorites: string[];
72
70
  savedSearches: string[];
73
71
  recent: string[];
74
- sourceWidth: number;
75
- detailsWidth: number;
76
- sourceCollapsed: boolean;
77
- detailsCollapsed: boolean;
78
72
  }
79
73
 
80
74
  const DEFAULT_STATE: PersistedLibraryState = {
81
75
  source: 'local',
82
76
  assetType: 'all',
83
77
  query: '',
84
- density: 'standard',
85
78
  sort: 'relevance',
86
79
  favorites: [],
87
80
  savedSearches: [],
88
81
  recent: [],
89
- sourceWidth: 210,
90
- detailsWidth: 360,
91
- sourceCollapsed: false,
92
- detailsCollapsed: false,
93
82
  };
94
83
 
95
84
  function readState(): PersistedLibraryState {
@@ -153,7 +142,10 @@ function SourcePane(props: {
153
142
  .filter((value) => value.toLowerCase().includes(props.categoryQuery.toLowerCase()))
154
143
  .slice(0, 80);
155
144
  return (
156
- <aside className="vgai-asset-library__sources" aria-label="Library sources and filters">
145
+ <aside
146
+ className="vgai-asset-library-navigator__source-filters"
147
+ aria-label="Library sources and filters"
148
+ >
157
149
  <div className="vgai-asset-facet-title">Sources</div>
158
150
  {SOURCES.map((entry) => (
159
151
  <Button
@@ -325,79 +317,55 @@ function AssetCardThumbnail({ asset }: { asset: OnlineAsset }) {
325
317
  );
326
318
  }
327
319
 
328
- function VirtualAssetGrid(props: {
320
+ function VirtualAssetList(props: {
329
321
  assets: readonly OnlineAsset[];
330
322
  selectedId: string | null;
331
- density: Density;
332
323
  loading: boolean;
333
324
  hasMore: boolean;
334
- isFavorite(asset: OnlineAsset): boolean;
335
325
  onSelect(asset: OnlineAsset): void;
336
326
  onOpen(asset: OnlineAsset): void;
337
327
  onLoadMore(): void;
338
328
  }) {
339
329
  const viewportRef = useRef<HTMLDivElement>(null);
340
- const typeahead = useRef({ value: '', timer: 0 });
341
- const [metrics, setMetrics] = useState({ width: 800, height: 600, scrollTop: 0 });
342
- const cardWidth = props.density === 'compact' ? 136 : props.density === 'large' ? 208 : 168;
343
- const rowHeight = props.density === 'compact' ? 178 : props.density === 'large' ? 254 : 214;
344
- const gap = 10;
345
- const columns = Math.max(1, Math.floor((metrics.width - gap) / (cardWidth + gap)));
346
- const rows = Math.ceil(props.assets.length / columns);
347
- const firstRow = Math.max(0, Math.floor(metrics.scrollTop / rowHeight) - 2);
348
- const lastRow = Math.min(rows, Math.ceil((metrics.scrollTop + metrics.height) / rowHeight) + 3);
349
- const firstIndex = firstRow * columns;
350
- const lastIndex = Math.min(props.assets.length, lastRow * columns);
330
+ const [metrics, setMetrics] = useState({ height: 320, scrollTop: 0 });
331
+ const rowHeight = 52;
332
+ const firstIndex = Math.max(0, Math.floor(metrics.scrollTop / rowHeight) - 3);
333
+ const lastIndex = Math.min(
334
+ props.assets.length,
335
+ Math.ceil((metrics.scrollTop + metrics.height) / rowHeight) + 4,
336
+ );
351
337
  const visible = props.assets.slice(firstIndex, lastIndex);
338
+ const selectedIndex = props.assets.findIndex((asset) => asset.id === props.selectedId);
352
339
 
353
340
  useEffect(() => {
354
341
  const element = viewportRef.current;
355
342
  if (!element || typeof ResizeObserver === 'undefined') return;
356
343
  const observer = new ResizeObserver(([entry]) => {
357
- if (!entry) return;
358
- setMetrics((current) => ({
359
- ...current,
360
- width: entry.contentRect.width,
361
- height: entry.contentRect.height,
362
- }));
344
+ if (entry) setMetrics((current) => ({ ...current, height: entry.contentRect.height }));
363
345
  });
364
346
  observer.observe(element);
365
347
  return () => observer.disconnect();
366
348
  }, []);
367
349
 
368
- useEffect(() => () => window.clearTimeout(typeahead.current.timer), []);
369
-
370
350
  const { hasMore, loading, onLoadMore } = props;
371
351
  useEffect(() => {
372
- if (hasMore && !loading && rows > 0 && lastRow >= rows - 2) onLoadMore();
373
- }, [hasMore, lastRow, loading, onLoadMore, rows]);
352
+ if (hasMore && !loading && lastIndex >= props.assets.length - 4) onLoadMore();
353
+ }, [hasMore, lastIndex, loading, onLoadMore, props.assets.length]);
374
354
 
375
- const selectedIndex = props.assets.findIndex((asset) => asset.id === props.selectedId);
376
- const moveSelection = (nextIndex: number) => {
377
- const asset = props.assets[Math.max(0, Math.min(props.assets.length - 1, nextIndex))];
355
+ const moveSelection = (index: number) => {
356
+ const nextIndex = Math.max(0, Math.min(props.assets.length - 1, index));
357
+ const asset = props.assets[nextIndex];
378
358
  if (!asset) return;
379
359
  props.onSelect(asset);
380
- const row = Math.floor(Math.max(0, nextIndex) / columns);
381
- if (viewportRef.current) viewportRef.current.scrollTop = row * rowHeight;
360
+ if (viewportRef.current) viewportRef.current.scrollTop = nextIndex * rowHeight;
382
361
  };
383
- const onGridKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
384
- const pageSize = Math.max(columns, Math.floor(metrics.height / rowHeight) * columns);
362
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
385
363
  switch (event.key) {
386
- case 'Enter':
387
- if (selectedIndex >= 0) props.onOpen(props.assets[selectedIndex]!);
388
- else return;
389
- break;
390
- case 'ArrowRight':
391
- moveSelection(selectedIndex + 1);
392
- break;
393
- case 'ArrowLeft':
394
- moveSelection(selectedIndex - 1);
395
- break;
396
364
  case 'ArrowDown':
397
- moveSelection(selectedIndex + columns);
365
+ moveSelection(selectedIndex + 1);
398
366
  break;
399
367
  case 'ArrowUp':
400
- moveSelection(selectedIndex - columns);
368
+ moveSelection(selectedIndex < 0 ? 0 : selectedIndex - 1);
401
369
  break;
402
370
  case 'Home':
403
371
  moveSelection(0);
@@ -406,24 +374,13 @@ function VirtualAssetGrid(props: {
406
374
  moveSelection(props.assets.length - 1);
407
375
  break;
408
376
  case 'PageDown':
409
- moveSelection(selectedIndex + pageSize);
377
+ moveSelection(selectedIndex + Math.max(1, Math.floor(metrics.height / rowHeight)));
410
378
  break;
411
379
  case 'PageUp':
412
- moveSelection(selectedIndex - pageSize);
380
+ moveSelection(selectedIndex - Math.max(1, Math.floor(metrics.height / rowHeight)));
413
381
  break;
414
- default: {
415
- if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
416
- window.clearTimeout(typeahead.current.timer);
417
- typeahead.current.value += event.key.toLowerCase();
418
- typeahead.current.timer = window.setTimeout(() => {
419
- typeahead.current.value = '';
420
- }, 700);
421
- const match = props.assets.findIndex((asset) =>
422
- asset.name.toLowerCase().startsWith(typeahead.current.value),
423
- );
424
- if (match < 0) return;
425
- moveSelection(match);
426
- }
382
+ default:
383
+ return;
427
384
  }
428
385
  event.preventDefault();
429
386
  };
@@ -431,24 +388,22 @@ function VirtualAssetGrid(props: {
431
388
  return (
432
389
  <div
433
390
  ref={viewportRef}
434
- className="vgai-asset-library__grid"
391
+ className="vgai-asset-library-navigator__results"
435
392
  role="listbox"
436
393
  aria-label="Asset results"
437
394
  aria-busy={props.loading}
438
395
  tabIndex={0}
396
+ onKeyDown={onKeyDown}
439
397
  onScroll={(event) =>
440
398
  setMetrics((current) => ({ ...current, scrollTop: event.currentTarget.scrollTop }))
441
399
  }
442
- onKeyDown={onGridKeyDown}
443
400
  >
444
401
  <div
445
- style={{ position: 'relative', height: Math.max(metrics.height, rows * rowHeight + gap) }}
402
+ className="vgai-asset-library-navigator__result-space"
403
+ style={{ height: Math.max(metrics.height, props.assets.length * rowHeight) }}
446
404
  >
447
405
  {visible.map((asset, localIndex) => {
448
406
  const index = firstIndex + localIndex;
449
- const column = index % columns;
450
- const row = Math.floor(index / columns);
451
- const downloaded = getDownloadedAssetPath(asset.source, asset.id) != null;
452
407
  return (
453
408
  <Button
454
409
  key={onlineAssetKey(asset)}
@@ -456,45 +411,182 @@ function VirtualAssetGrid(props: {
456
411
  role="option"
457
412
  aria-selected={props.selectedId === asset.id}
458
413
  aria-label={`${asset.name}, ${asset.type}, ${asset.source}`}
459
- className="vgai-asset-card"
460
- style={{ left: gap + column * (cardWidth + gap), top: gap + row * rowHeight }}
414
+ className="vgai-asset-library-row"
415
+ style={{ top: index * rowHeight }}
461
416
  onClick={() => props.onSelect(asset)}
462
417
  onDoubleClick={() => props.onOpen(asset)}
418
+ onKeyDown={(event) => {
419
+ if (event.key !== 'Enter') return;
420
+ props.onOpen(asset);
421
+ event.preventDefault();
422
+ }}
463
423
  >
464
424
  <AssetCardThumbnail asset={asset} />
465
- <span className="vgai-asset-card__name" data-testid="online-asset-name">
466
- {asset.name}
467
- </span>
468
- <span className="vgai-asset-card__meta">
469
- <span>{asset.type}</span>
470
- <span>·</span>
471
- <span>{asset.source === 'local' ? 'Catalog' : asset.source}</span>
472
- </span>
473
- <span className="vgai-asset-card__badges">
474
- {downloaded && <span className="vgai-asset-badge">In project</span>}
475
- {!downloaded && asset.cloudHosted && (
476
- <span className="vgai-asset-badge">
477
- <EditorIcon icon={faCloud} /> Cloud
478
- </span>
479
- )}
480
- {(asset.variantCount ?? 0) > 1 && (
481
- <span className="vgai-asset-badge">{asset.variantCount} variants</span>
482
- )}
483
- {props.isFavorite(asset) && <span className="vgai-asset-badge">Favorite</span>}
425
+ <span className="vgai-asset-library-row__copy">
426
+ <span className="vgai-asset-library-row__name" data-testid="online-asset-name">
427
+ {asset.name}
428
+ </span>
429
+ <span className="vgai-asset-library-row__meta">
430
+ {asset.type} · {asset.source === 'local' ? 'Catalog' : asset.source}
431
+ </span>
484
432
  </span>
485
433
  </Button>
486
434
  );
487
435
  })}
488
436
  {props.loading && (
489
- <div role="status" style={{ position: 'absolute', left: 16, top: rows * rowHeight + 12 }}>
437
+ <span className="vgai-asset-library-navigator__loading" role="status">
490
438
  <EditorIcon icon={faSpinner} spin /> Loading assets…
491
- </div>
439
+ </span>
492
440
  )}
493
441
  </div>
494
442
  </div>
495
443
  );
496
444
  }
497
445
 
446
+ function NavigatorAssetLibrary(props: {
447
+ assets: readonly OnlineAsset[];
448
+ selectedId: string | null;
449
+ source: Source;
450
+ assetType: string;
451
+ sort: Sort;
452
+ searchQuery: string;
453
+ total: number;
454
+ loading: boolean;
455
+ error: string | null;
456
+ hasMore: boolean;
457
+ filtersOpen: boolean;
458
+ filters: React.ReactNode;
459
+ canSaveSearch: boolean;
460
+ onSource(source: Source): void;
461
+ onType(type: string): void;
462
+ onSort(sort: Sort): void;
463
+ onSearch(query: string): void;
464
+ onSelect(asset: OnlineAsset): void;
465
+ onOpen(asset: OnlineAsset): void;
466
+ onLoadMore(): void;
467
+ onRetry(): void;
468
+ onSaveSearch(): void;
469
+ onToggleFilters(): void;
470
+ }) {
471
+ const sourceConfig = SOURCES.find((entry) => entry.value === props.source)!;
472
+ return (
473
+ <div className="vgai-asset-library-navigator">
474
+ <div className="vgai-asset-library-navigator__controls">
475
+ <div className="vgai-asset-library-navigator__search-row">
476
+ <TextInput
477
+ className="vgai-input"
478
+ aria-label="Search asset library"
479
+ placeholder="Search Library"
480
+ value={props.searchQuery}
481
+ onChange={(event) => props.onSearch(event.target.value)}
482
+ />
483
+ <Button
484
+ variant="ghost"
485
+ size="compact"
486
+ aria-expanded={props.filtersOpen}
487
+ onClick={props.onToggleFilters}
488
+ >
489
+ Filters
490
+ </Button>
491
+ </div>
492
+ <div className="vgai-asset-library-navigator__filters">
493
+ <Select
494
+ className="vgai-select"
495
+ aria-label="Library source"
496
+ value={props.source}
497
+ onChange={(event) => props.onSource(event.target.value as Source)}
498
+ >
499
+ {SOURCES.map((entry) => (
500
+ <option key={entry.value} value={entry.value}>
501
+ {entry.label}
502
+ </option>
503
+ ))}
504
+ </Select>
505
+ <Select
506
+ className="vgai-select"
507
+ aria-label="Library asset type"
508
+ value={props.assetType}
509
+ onChange={(event) => props.onType(event.target.value)}
510
+ >
511
+ {sourceConfig.types.map((entry) => (
512
+ <option key={entry.value} value={entry.value}>
513
+ {entry.label}
514
+ </option>
515
+ ))}
516
+ </Select>
517
+ <Select
518
+ className="vgai-select"
519
+ aria-label="Sort assets"
520
+ value={props.sort}
521
+ onChange={(event) => props.onSort(event.target.value as Sort)}
522
+ >
523
+ <option value="relevance">Relevant</option>
524
+ <option value="name">Name</option>
525
+ <option value="source">Source</option>
526
+ <option value="size">Size</option>
527
+ <option value="recently-added">Newest</option>
528
+ <option value="recently-used">Recent</option>
529
+ </Select>
530
+ </div>
531
+ <span className="vgai-asset-library-navigator__status" role="status" aria-live="polite">
532
+ {props.loading && props.assets.length === 0
533
+ ? 'Loading…'
534
+ : `${props.assets.length.toLocaleString()} of ${props.total.toLocaleString()}`}
535
+ </span>
536
+ </div>
537
+ {props.filtersOpen && (
538
+ <div className="vgai-asset-library-navigator__filter-drawer">
539
+ <div className="vgai-asset-library-navigator__filter-heading">
540
+ <span>Filters and saved views</span>
541
+ <Button variant="ghost" size="compact" onClick={props.onToggleFilters}>
542
+ Done
543
+ </Button>
544
+ </div>
545
+ {props.canSaveSearch && (
546
+ <Button
547
+ className="vgai-asset-library-navigator__save-search"
548
+ variant="ghost"
549
+ size="compact"
550
+ onClick={props.onSaveSearch}
551
+ >
552
+ Save current search
553
+ </Button>
554
+ )}
555
+ {props.filters}
556
+ </div>
557
+ )}
558
+ {props.error ? (
559
+ <StateSurface
560
+ compact
561
+ title="Library unavailable"
562
+ description={props.error}
563
+ action={
564
+ <Button variant="ghost" size="compact" onClick={props.onRetry}>
565
+ Retry
566
+ </Button>
567
+ }
568
+ />
569
+ ) : props.assets.length === 0 && !props.loading ? (
570
+ <StateSurface
571
+ compact
572
+ title="No matching assets"
573
+ description="Try another search or type."
574
+ />
575
+ ) : (
576
+ <VirtualAssetList
577
+ assets={props.assets}
578
+ selectedId={props.selectedId}
579
+ loading={props.loading}
580
+ hasMore={props.hasMore}
581
+ onSelect={props.onSelect}
582
+ onOpen={props.onOpen}
583
+ onLoadMore={props.onLoadMore}
584
+ />
585
+ )}
586
+ </div>
587
+ );
588
+ }
589
+
498
590
  export function OnlineAssetBrowser() {
499
591
  const store = useEditorStore();
500
592
  const initial = useMemo(readState, []);
@@ -503,15 +595,11 @@ export function OnlineAssetBrowser() {
503
595
  const [category, setCategory] = useState('');
504
596
  const [categoryQuery, setCategoryQuery] = useState('');
505
597
  const [searchQuery, setSearchQuery] = useState(initial.query);
506
- const [density, setDensity] = useState<Density>(initial.density);
507
598
  const [sort, setSort] = useState<Sort>(initial.sort);
508
- const [favorites, setFavorites] = useState(new Set(initial.favorites));
599
+ const [favorites] = useState(new Set(initial.favorites));
509
600
  const [savedSearches, setSavedSearches] = useState(initial.savedSearches);
510
601
  const [recent, setRecent] = useState(initial.recent);
511
- const [sourceWidth, setSourceWidth] = useState(initial.sourceWidth);
512
- const [detailsWidth, setDetailsWidth] = useState(initial.detailsWidth);
513
- const [sourceCollapsed, setSourceCollapsed] = useState(initial.sourceCollapsed);
514
- const [detailsCollapsed, setDetailsCollapsed] = useState(initial.detailsCollapsed);
602
+ const [navigatorFiltersOpen, setNavigatorFiltersOpen] = useState(false);
515
603
  const [favoritesOnly, setFavoritesOnly] = useState(false);
516
604
  const [runtimeReadyOnly, setRuntimeReadyOnly] = useState(false);
517
605
  const [animatedOnly, setAnimatedOnly] = useState(false);
@@ -536,37 +624,30 @@ export function OnlineAssetBrowser() {
536
624
  source,
537
625
  assetType,
538
626
  query: searchQuery,
539
- density,
540
627
  sort,
541
628
  favorites: [...favorites],
542
629
  savedSearches,
543
630
  recent,
544
- sourceWidth,
545
- detailsWidth,
546
- sourceCollapsed,
547
- detailsCollapsed,
548
631
  } satisfies PersistedLibraryState),
549
632
  );
550
- }, [
551
- assetType,
552
- density,
553
- detailsCollapsed,
554
- detailsWidth,
555
- favorites,
556
- recent,
557
- savedSearches,
558
- searchQuery,
559
- sort,
560
- source,
561
- sourceCollapsed,
562
- sourceWidth,
563
- ]);
633
+ }, [assetType, favorites, recent, savedSearches, searchQuery, sort, source]);
564
634
 
565
635
  useEffect(() => {
566
636
  loadDownloadedAssets();
567
637
  return onAssetDownloaded(() => setAssets((current) => [...current]));
568
638
  }, []);
569
639
 
640
+ useEffect(() => {
641
+ const openSelected = (event: Event) => {
642
+ openOnlineAssetDocument(
643
+ store,
644
+ (event as CustomEvent<ReturnType<typeof toOnlineInfo>>).detail,
645
+ );
646
+ };
647
+ window.addEventListener('editor:open-online-asset', openSelected);
648
+ return () => window.removeEventListener('editor:open-online-asset', openSelected);
649
+ }, [store]);
650
+
570
651
  useEffect(() => {
571
652
  if (source === 'polyhaven' || source === 'local') {
572
653
  getAssetLibraryCategories(assetType, source)
@@ -609,6 +690,7 @@ export function OnlineAssetBrowser() {
609
690
  const timer = window.setTimeout(() => {
610
691
  setOffset(0);
611
692
  setSelectedId(null);
693
+ if (getSelectedAsset()?.origin === 'library') clearSelectedAsset();
612
694
  void doSearch(0, false);
613
695
  }, 250);
614
696
  return () => window.clearTimeout(timer);
@@ -668,9 +750,23 @@ export function OnlineAssetBrowser() {
668
750
  health: count(assets.map((asset) => asset.capabilities?.health ?? 'unsupported')),
669
751
  };
670
752
  }, [assets]);
671
- const selected = assets.find((asset) => asset.id === selectedId) ?? null;
672
-
673
- const onSelect = (asset: OnlineAsset) => setSelectedId(asset.id);
753
+ const onSelect = (asset: OnlineAsset) => {
754
+ setSelectedId(asset.id);
755
+ const localPath = getDownloadedAssetPath(asset.source, asset.id);
756
+ const online = toOnlineInfo(asset);
757
+ setSelectedAsset({
758
+ path: localPath ? `/${localPath.replace(/^\/+/, '')}` : `online:${asset.source}:${asset.id}`,
759
+ name: asset.name,
760
+ kind:
761
+ asset.type === 'model'
762
+ ? 'model'
763
+ : asset.type === 'animation' || asset.type === 'source'
764
+ ? 'source'
765
+ : 'image',
766
+ origin: 'library',
767
+ online,
768
+ });
769
+ };
674
770
  const onOpen = (asset: OnlineAsset) => {
675
771
  openOnlineAssetDocument(store, toOnlineInfo(asset));
676
772
  const key = onlineAssetKey(asset);
@@ -681,219 +777,75 @@ export function OnlineAssetBrowser() {
681
777
  setAssetType(SOURCES.find((entry) => entry.value === nextSource)!.types[0]!.value);
682
778
  setCategory('');
683
779
  };
684
- const toggleFavorite = (asset: OnlineAsset) => {
685
- const key = onlineAssetKey(asset);
686
- setFavorites((current) => {
687
- const next = new Set(current);
688
- if (next.has(key)) next.delete(key);
689
- else next.add(key);
690
- return next;
691
- });
692
- };
780
+ const sourcePane = (
781
+ <SourcePane
782
+ source={source}
783
+ assetType={assetType}
784
+ category={category}
785
+ categoryQuery={categoryQuery}
786
+ categories={categories}
787
+ savedSearches={savedSearches}
788
+ favoritesOnly={favoritesOnly}
789
+ animatedOnly={animatedOnly}
790
+ runtimeReadyOnly={runtimeReadyOnly}
791
+ downloaded={downloaded}
792
+ license={license}
793
+ delivery={delivery}
794
+ health={health}
795
+ licenseCounts={facetCounts.licenses}
796
+ deliveryCounts={facetCounts.deliveries}
797
+ healthCounts={facetCounts.health}
798
+ onSource={onSource}
799
+ onType={(value) => {
800
+ setAssetType(value);
801
+ setCategory('');
802
+ }}
803
+ onCategory={setCategory}
804
+ onCategoryQuery={setCategoryQuery}
805
+ onSavedSearch={setSearchQuery}
806
+ onFavoritesOnly={setFavoritesOnly}
807
+ onAnimatedOnly={setAnimatedOnly}
808
+ onRuntimeReadyOnly={setRuntimeReadyOnly}
809
+ onDownloaded={setDownloaded}
810
+ onLicense={setLicense}
811
+ onDelivery={setDelivery}
812
+ onHealth={setHealth}
813
+ />
814
+ );
693
815
 
694
- return (
695
- <div
696
- className="vgai-asset-library"
697
- data-density={density}
698
- data-source-collapsed={sourceCollapsed}
699
- data-details-collapsed={detailsCollapsed}
700
- style={
701
- {
702
- '--asset-source-width': `${sourceCollapsed ? 0 : sourceWidth}px`,
703
- '--asset-details-width': `${detailsCollapsed ? 0 : detailsWidth}px`,
704
- } as React.CSSProperties
705
- }
706
- >
707
- <SourcePane
708
- source={source}
709
- assetType={assetType}
710
- category={category}
711
- categoryQuery={categoryQuery}
712
- categories={categories}
713
- savedSearches={savedSearches}
714
- favoritesOnly={favoritesOnly}
715
- animatedOnly={animatedOnly}
716
- runtimeReadyOnly={runtimeReadyOnly}
717
- downloaded={downloaded}
718
- license={license}
719
- delivery={delivery}
720
- health={health}
721
- licenseCounts={facetCounts.licenses}
722
- deliveryCounts={facetCounts.deliveries}
723
- healthCounts={facetCounts.health}
724
- onSource={onSource}
725
- onType={(value) => {
726
- setAssetType(value);
727
- setCategory('');
728
- }}
729
- onCategory={setCategory}
730
- onCategoryQuery={setCategoryQuery}
731
- onSavedSearch={setSearchQuery}
732
- onFavoritesOnly={setFavoritesOnly}
733
- onAnimatedOnly={setAnimatedOnly}
734
- onRuntimeReadyOnly={setRuntimeReadyOnly}
735
- onDownloaded={setDownloaded}
736
- onLicense={setLicense}
737
- onDelivery={setDelivery}
738
- onHealth={setHealth}
739
- />
740
- <main className="vgai-asset-library__results">
741
- <div className="vgai-asset-library__toolbar">
742
- <Button
743
- className="vgai-asset-library__filters-toggle"
744
- variant="ghost"
745
- size="comfortable"
746
- onClick={() => setSourceCollapsed((value) => !value)}
747
- >
748
- {sourceCollapsed ? 'Show filters' : 'Hide filters'}
749
- </Button>
750
- <TextInput
751
- className="vgai-input"
752
- aria-label="Search asset library"
753
- placeholder="Search models, packs, authors, clips…"
754
- value={searchQuery}
755
- onChange={(event) => setSearchQuery(event.target.value)}
756
- style={{ flex: '1 1 220px' }}
757
- />
758
- <Select
759
- className="vgai-select vgai-asset-library__sort"
760
- aria-label="Sort assets"
761
- value={sort}
762
- onChange={(event) => setSort(event.target.value as Sort)}
763
- >
764
- <option value="relevance">Relevance</option>
765
- <option value="name">Name</option>
766
- <option value="source">Source</option>
767
- <option value="size">Size</option>
768
- <option value="recently-added">Recently added</option>
769
- <option value="recently-used">Recently used</option>
770
- </Select>
771
- <Select
772
- className="vgai-select vgai-asset-library__density"
773
- aria-label="Card density"
774
- value={density}
775
- onChange={(event) => setDensity(event.target.value as Density)}
776
- >
777
- <option value="compact">Compact</option>
778
- <option value="standard">Standard</option>
779
- <option value="large">Large</option>
780
- </Select>
781
- <Button
782
- className="vgai-asset-library__save-search"
783
- variant="ghost"
784
- size="comfortable"
785
- disabled={!searchQuery || savedSearches.includes(searchQuery)}
786
- onClick={() => setSavedSearches((current) => [...current, searchQuery])}
787
- >
788
- Save search
789
- </Button>
790
- <Button
791
- className="vgai-asset-library__details-toggle"
792
- variant="ghost"
793
- size="comfortable"
794
- onClick={() => setDetailsCollapsed((value) => !value)}
795
- >
796
- {detailsCollapsed ? 'Show details' : 'Hide details'}
797
- </Button>
798
- <span
799
- className="vgai-asset-library__status"
800
- role="status"
801
- aria-live="polite"
802
- style={{ fontSize: 11, color: text[3] }}
803
- >
804
- {filteredAssets.length} loaded · {total.toLocaleString()} total
805
- </span>
806
- </div>
807
- {error ? (
808
- <div role="alert" style={{ padding: 32, color: text[2], textAlign: 'center' }}>
809
- <div>{error}</div>
810
- <Button
811
- variant="ghost"
812
- size="comfortable"
813
- onClick={() => {
814
- setOffset(0);
815
- void doSearch(0, false);
816
- }}
817
- >
818
- Retry
819
- </Button>
820
- </div>
821
- ) : filteredAssets.length === 0 && !loading ? (
822
- <div role="status" style={{ padding: 32, color: text[3], textAlign: 'center' }}>
823
- No assets match the current search and filters.
824
- </div>
825
- ) : (
826
- <VirtualAssetGrid
827
- assets={filteredAssets}
828
- selectedId={selectedId}
829
- density={density}
830
- loading={loading}
831
- hasMore={assets.length < total}
832
- isFavorite={(asset) => favorites.has(onlineAssetKey(asset))}
833
- onSelect={onSelect}
834
- onOpen={onOpen}
835
- onLoadMore={loadMore}
836
- />
837
- )}
838
- </main>
839
- <aside className="vgai-asset-library__details" aria-label="Selected asset details">
840
- <div className="vgai-asset-pane-size">
841
- <label>
842
- Detail width
843
- <TextInput
844
- aria-label="Detail pane width"
845
- type="range"
846
- min="280"
847
- max="640"
848
- value={detailsWidth}
849
- onChange={(event) => setDetailsWidth(Number(event.target.value))}
850
- />
851
- </label>
852
- </div>
853
- {selected ? (
854
- <>
855
- <div
856
- style={{
857
- display: 'flex',
858
- gap: 8,
859
- padding: '8px 10px',
860
- borderBottom: '1px solid var(--vgai-border-1)',
861
- }}
862
- >
863
- <Button variant="solid" size="comfortable" onClick={() => onOpen(selected)}>
864
- Open in Asset Editor
865
- </Button>
866
- <Button variant="ghost" size="comfortable" onClick={() => toggleFavorite(selected)}>
867
- {favorites.has(onlineAssetKey(selected)) ? 'Remove favorite' : 'Add favorite'}
868
- </Button>
869
- {getDownloadedAssetPath(selected.source, selected.id) && (
870
- <span className="vgai-asset-badge">
871
- <EditorIcon icon={faCheck} /> In project
872
- </span>
873
- )}
874
- </div>
875
- <OnlineAssetDetail
876
- online={toOnlineInfo(selected)}
877
- onResolved={() => setAssets((current) => [...current])}
878
- />
879
- </>
880
- ) : (
881
- <div role="status" style={{ padding: 24, color: text[3] }}>
882
- Select an asset to inspect its preview, variants, license, and project status.
883
- </div>
884
- )}
885
- </aside>
886
- {!sourceCollapsed && (
887
- <TextInput
888
- className="vgai-asset-source-size"
889
- aria-label="Source pane width"
890
- type="range"
891
- min="176"
892
- max="360"
893
- value={sourceWidth}
894
- onChange={(event) => setSourceWidth(Number(event.target.value))}
895
- />
896
- )}
897
- </div>
816
+ const navigatorLayout = (
817
+ <NavigatorAssetLibrary
818
+ assets={filteredAssets}
819
+ selectedId={selectedId}
820
+ source={source}
821
+ assetType={assetType}
822
+ sort={sort}
823
+ searchQuery={searchQuery}
824
+ total={total}
825
+ loading={loading}
826
+ error={error}
827
+ hasMore={assets.length < total}
828
+ filtersOpen={navigatorFiltersOpen}
829
+ filters={sourcePane}
830
+ canSaveSearch={Boolean(searchQuery && !savedSearches.includes(searchQuery))}
831
+ onSource={onSource}
832
+ onType={(value) => {
833
+ setAssetType(value);
834
+ setCategory('');
835
+ }}
836
+ onSort={setSort}
837
+ onSearch={setSearchQuery}
838
+ onSelect={onSelect}
839
+ onOpen={onOpen}
840
+ onLoadMore={loadMore}
841
+ onSaveSearch={() => setSavedSearches((current) => [...current, searchQuery])}
842
+ onToggleFilters={() => setNavigatorFiltersOpen((value) => !value)}
843
+ onRetry={() => {
844
+ setOffset(0);
845
+ void doSearch(0, false);
846
+ }}
847
+ />
898
848
  );
849
+
850
+ return navigatorLayout;
899
851
  }