@vgai/editor 0.4.0-canary.20260715.1 → 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 (46) hide show
  1. package/dist/assets/{axe-CyiTVRi6.js → axe-BJjvBeOZ.js} +1 -1
  2. package/dist/assets/{browserAll-fGZ_P1P8.js → browserAll-Dn0pgD87.js} +1 -1
  3. package/dist/assets/{create-runtime-BzSHBTYw.js → create-runtime-BF9OD-U8.js} +1 -1
  4. package/dist/assets/{game-NUAnyPkS.js → game-B346oj_f.js} +1 -1
  5. package/dist/assets/{game-uR_lv3Mp.js → game-BCNN63uV.js} +1 -1
  6. package/dist/assets/{game-HqybI_X7.js → game-BR0GBNgF.js} +1 -1
  7. package/dist/assets/{game-DQ7YHNS8.js → game-CLNm2g0y.js} +1 -1
  8. package/dist/assets/{game-DX6Obomf.js → game-DQxhCYsZ.js} +1 -1
  9. package/dist/assets/{game-DoUJj8vY.js → game-DeTGdgYt.js} +1 -1
  10. package/dist/assets/{game-loop-o2CcBMFY.js → game-loop-t5W_oqxz.js} +1 -1
  11. package/dist/assets/{game-source-C8HM7gbP.js → game-source-DzE9taML.js} +1 -1
  12. package/dist/assets/{game-ck_dNIE6.js → game-u6c2Patu.js} +1 -1
  13. package/dist/assets/{game-hIf5LD4E.js → game-umrHIqFB.js} +1 -1
  14. package/dist/assets/{index-CoSRCKPS.js → index-CgcH1RpF.js} +1 -1
  15. package/dist/assets/{index-D93bBuUl.js → index-D3tcNB2T.js} +1 -1
  16. package/dist/assets/{index-B-HA1uu3.js → index-DbQzGLxv.js} +769 -769
  17. package/dist/assets/{index-CSBAZwMx.js → index-Fc8Ezr20.js} +1 -1
  18. package/dist/assets/index-bAGhEnnO.css +1 -0
  19. package/dist/assets/{ingest-siblings-CCkkE2h7.js → ingest-siblings-CW_xbvrF.js} +1 -1
  20. package/dist/assets/{mount-manifest-B2ECssU2.js → mount-manifest-DgUscyaD.js} +1 -1
  21. package/dist/assets/{react-18-Bmj05S8J.js → react-18-C4tVCii_.js} +1 -1
  22. package/dist/assets/webworkerAll-BA4aq7xq.js +1 -0
  23. package/dist/assets/{world-documents-DXPcAEA0.js → world-documents-BSaA5YIF.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/package.json +3 -3
  26. package/src/asset-editor-context.tsx +130 -0
  27. package/src/asset-preview.ts +4 -0
  28. package/src/asset-selection.ts +9 -4
  29. package/src/asset-workflow/thumbnail-system.ts +16 -7
  30. package/src/components/AssetBrowser.tsx +15 -7
  31. package/src/components/AssetEditorShell.tsx +42 -112
  32. package/src/components/AssetImportDetails.tsx +24 -6
  33. package/src/components/AssetLibraryPanel.tsx +1 -1
  34. package/src/components/GameHierarchy.tsx +26 -1
  35. package/src/components/Inspector.tsx +38 -2
  36. package/src/components/OnlineAssetBrowser.tsx +40 -409
  37. package/src/components/WorkspaceDock.tsx +6 -12
  38. package/src/components/asset-documents.tsx +41 -12
  39. package/src/components/asset-selection-section.tsx +34 -1
  40. package/src/components/asset-viewers/ModelViewer.tsx +7 -4
  41. package/src/components/asset-viewers/OnlineAssetDetail.tsx +233 -205
  42. package/src/components/asset-workflow.css +39 -220
  43. package/src/editor-api.ts +1 -1
  44. package/template/vgai.game.json +1 -1
  45. package/dist/assets/index-bu01fqch.css +0 -1
  46. package/dist/assets/webworkerAll-BC6gMOLZ.js +0 -1
@@ -1,20 +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 {
10
- Button,
11
- Checkbox,
12
- EditorIcon,
13
- Select,
14
- StateSurface,
15
- TextInput,
16
- text,
17
- } from '../design-system';
10
+ import { Button, Checkbox, EditorIcon, Select, StateSurface, TextInput } from '../design-system';
18
11
  import { useEditorStore } from '../EditorContext';
19
12
  import {
20
13
  getAssetLibraryCategories,
@@ -26,11 +19,9 @@ import {
26
19
  } from '../editor-api';
27
20
  import { getCurrentProject } from '../project-manager';
28
21
  import { openOnlineAssetDocument } from './asset-documents';
29
- import { OnlineAssetDetail } from './asset-viewers/OnlineAssetDetail';
30
22
  import './asset-workflow.css';
31
23
 
32
24
  type Source = 'local' | 'polyhaven' | 'ambientcg';
33
- type Density = 'compact' | 'standard' | 'large';
34
25
  type Sort = AssetLibrarySort;
35
26
 
36
27
  interface SourceOption {
@@ -74,30 +65,20 @@ interface PersistedLibraryState {
74
65
  source: Source;
75
66
  assetType: string;
76
67
  query: string;
77
- density: Density;
78
68
  sort: Sort;
79
69
  favorites: string[];
80
70
  savedSearches: string[];
81
71
  recent: string[];
82
- sourceWidth: number;
83
- detailsWidth: number;
84
- sourceCollapsed: boolean;
85
- detailsCollapsed: boolean;
86
72
  }
87
73
 
88
74
  const DEFAULT_STATE: PersistedLibraryState = {
89
75
  source: 'local',
90
76
  assetType: 'all',
91
77
  query: '',
92
- density: 'standard',
93
78
  sort: 'relevance',
94
79
  favorites: [],
95
80
  savedSearches: [],
96
81
  recent: [],
97
- sourceWidth: 210,
98
- detailsWidth: 360,
99
- sourceCollapsed: false,
100
- detailsCollapsed: false,
101
82
  };
102
83
 
103
84
  function readState(): PersistedLibraryState {
@@ -161,7 +142,10 @@ function SourcePane(props: {
161
142
  .filter((value) => value.toLowerCase().includes(props.categoryQuery.toLowerCase()))
162
143
  .slice(0, 80);
163
144
  return (
164
- <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
+ >
165
149
  <div className="vgai-asset-facet-title">Sources</div>
166
150
  {SOURCES.map((entry) => (
167
151
  <Button
@@ -333,180 +317,6 @@ function AssetCardThumbnail({ asset }: { asset: OnlineAsset }) {
333
317
  );
334
318
  }
335
319
 
336
- function VirtualAssetGrid(props: {
337
- assets: readonly OnlineAsset[];
338
- selectedId: string | null;
339
- density: Density;
340
- loading: boolean;
341
- hasMore: boolean;
342
- isFavorite(asset: OnlineAsset): boolean;
343
- onSelect(asset: OnlineAsset): void;
344
- onOpen(asset: OnlineAsset): void;
345
- onLoadMore(): void;
346
- }) {
347
- const viewportRef = useRef<HTMLDivElement>(null);
348
- const typeahead = useRef({ value: '', timer: 0 });
349
- const [metrics, setMetrics] = useState({ width: 800, height: 600, scrollTop: 0 });
350
- const cardWidth = props.density === 'compact' ? 136 : props.density === 'large' ? 208 : 168;
351
- const rowHeight = props.density === 'compact' ? 178 : props.density === 'large' ? 254 : 214;
352
- const gap = 10;
353
- const columns = Math.max(1, Math.floor((metrics.width - gap) / (cardWidth + gap)));
354
- const rows = Math.ceil(props.assets.length / columns);
355
- const firstRow = Math.max(0, Math.floor(metrics.scrollTop / rowHeight) - 2);
356
- const lastRow = Math.min(rows, Math.ceil((metrics.scrollTop + metrics.height) / rowHeight) + 3);
357
- const firstIndex = firstRow * columns;
358
- const lastIndex = Math.min(props.assets.length, lastRow * columns);
359
- const visible = props.assets.slice(firstIndex, lastIndex);
360
-
361
- useEffect(() => {
362
- const element = viewportRef.current;
363
- if (!element || typeof ResizeObserver === 'undefined') return;
364
- const observer = new ResizeObserver(([entry]) => {
365
- if (!entry) return;
366
- setMetrics((current) => ({
367
- ...current,
368
- width: entry.contentRect.width,
369
- height: entry.contentRect.height,
370
- }));
371
- });
372
- observer.observe(element);
373
- return () => observer.disconnect();
374
- }, []);
375
-
376
- useEffect(() => () => window.clearTimeout(typeahead.current.timer), []);
377
-
378
- const { hasMore, loading, onLoadMore } = props;
379
- useEffect(() => {
380
- if (hasMore && !loading && rows > 0 && lastRow >= rows - 2) onLoadMore();
381
- }, [hasMore, lastRow, loading, onLoadMore, rows]);
382
-
383
- const selectedIndex = props.assets.findIndex((asset) => asset.id === props.selectedId);
384
- const moveSelection = (nextIndex: number) => {
385
- const asset = props.assets[Math.max(0, Math.min(props.assets.length - 1, nextIndex))];
386
- if (!asset) return;
387
- props.onSelect(asset);
388
- const row = Math.floor(Math.max(0, nextIndex) / columns);
389
- if (viewportRef.current) viewportRef.current.scrollTop = row * rowHeight;
390
- };
391
- const onGridKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
392
- const pageSize = Math.max(columns, Math.floor(metrics.height / rowHeight) * columns);
393
- switch (event.key) {
394
- case 'Enter':
395
- if (selectedIndex >= 0) props.onOpen(props.assets[selectedIndex]!);
396
- else return;
397
- break;
398
- case 'ArrowRight':
399
- moveSelection(selectedIndex + 1);
400
- break;
401
- case 'ArrowLeft':
402
- moveSelection(selectedIndex - 1);
403
- break;
404
- case 'ArrowDown':
405
- moveSelection(selectedIndex + columns);
406
- break;
407
- case 'ArrowUp':
408
- moveSelection(selectedIndex - columns);
409
- break;
410
- case 'Home':
411
- moveSelection(0);
412
- break;
413
- case 'End':
414
- moveSelection(props.assets.length - 1);
415
- break;
416
- case 'PageDown':
417
- moveSelection(selectedIndex + pageSize);
418
- break;
419
- case 'PageUp':
420
- moveSelection(selectedIndex - pageSize);
421
- break;
422
- default: {
423
- if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
424
- window.clearTimeout(typeahead.current.timer);
425
- typeahead.current.value += event.key.toLowerCase();
426
- typeahead.current.timer = window.setTimeout(() => {
427
- typeahead.current.value = '';
428
- }, 700);
429
- const match = props.assets.findIndex((asset) =>
430
- asset.name.toLowerCase().startsWith(typeahead.current.value),
431
- );
432
- if (match < 0) return;
433
- moveSelection(match);
434
- }
435
- }
436
- event.preventDefault();
437
- };
438
-
439
- return (
440
- <div
441
- ref={viewportRef}
442
- className="vgai-asset-library__grid"
443
- role="listbox"
444
- aria-label="Asset results"
445
- aria-busy={props.loading}
446
- tabIndex={0}
447
- onScroll={(event) =>
448
- setMetrics((current) => ({ ...current, scrollTop: event.currentTarget.scrollTop }))
449
- }
450
- onKeyDown={onGridKeyDown}
451
- >
452
- <div
453
- style={{ position: 'relative', height: Math.max(metrics.height, rows * rowHeight + gap) }}
454
- >
455
- {visible.map((asset, localIndex) => {
456
- const index = firstIndex + localIndex;
457
- const column = index % columns;
458
- const row = Math.floor(index / columns);
459
- const downloaded = getDownloadedAssetPath(asset.source, asset.id) != null;
460
- return (
461
- <Button
462
- key={onlineAssetKey(asset)}
463
- type="button"
464
- role="option"
465
- aria-selected={props.selectedId === asset.id}
466
- aria-label={`${asset.name}, ${asset.type}, ${asset.source}`}
467
- className="vgai-asset-card"
468
- style={{ left: gap + column * (cardWidth + gap), top: gap + row * rowHeight }}
469
- onClick={() => props.onSelect(asset)}
470
- onDoubleClick={() => props.onOpen(asset)}
471
- >
472
- <AssetCardThumbnail asset={asset} />
473
- <span className="vgai-asset-card__name" data-testid="online-asset-name">
474
- {asset.name}
475
- </span>
476
- <span className="vgai-asset-card__meta">
477
- <span>{asset.type}</span>
478
- <span>·</span>
479
- <span>{asset.source === 'local' ? 'Catalog' : asset.source}</span>
480
- </span>
481
- <span className="vgai-asset-card__badges">
482
- {downloaded && <span className="vgai-asset-badge">In project</span>}
483
- {!downloaded && asset.cloudHosted && (
484
- <span className="vgai-asset-badge">
485
- <EditorIcon icon={faCloud} /> Cloud
486
- </span>
487
- )}
488
- {(asset.variantCount ?? 0) > 1 && (
489
- <span className="vgai-asset-badge">{asset.variantCount} variants</span>
490
- )}
491
- {props.isFavorite(asset) && <span className="vgai-asset-badge">Favorite</span>}
492
- </span>
493
- </Button>
494
- );
495
- })}
496
- {props.loading && (
497
- <div role="status" style={{ position: 'absolute', left: 16, top: rows * rowHeight + 12 }}>
498
- <EditorIcon icon={faSpinner} spin /> Loading assets…
499
- </div>
500
- )}
501
- </div>
502
- </div>
503
- );
504
- }
505
-
506
- interface OnlineAssetBrowserProps {
507
- layout?: 'workspace' | 'navigator';
508
- }
509
-
510
320
  function VirtualAssetList(props: {
511
321
  assets: readonly OnlineAsset[];
512
322
  selectedId: string | null;
@@ -777,7 +587,7 @@ function NavigatorAssetLibrary(props: {
777
587
  );
778
588
  }
779
589
 
780
- export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserProps = {}) {
590
+ export function OnlineAssetBrowser() {
781
591
  const store = useEditorStore();
782
592
  const initial = useMemo(readState, []);
783
593
  const [source, setSource] = useState<Source>(initial.source);
@@ -785,15 +595,10 @@ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserP
785
595
  const [category, setCategory] = useState('');
786
596
  const [categoryQuery, setCategoryQuery] = useState('');
787
597
  const [searchQuery, setSearchQuery] = useState(initial.query);
788
- const [density, setDensity] = useState<Density>(initial.density);
789
598
  const [sort, setSort] = useState<Sort>(initial.sort);
790
- const [favorites, setFavorites] = useState(new Set(initial.favorites));
599
+ const [favorites] = useState(new Set(initial.favorites));
791
600
  const [savedSearches, setSavedSearches] = useState(initial.savedSearches);
792
601
  const [recent, setRecent] = useState(initial.recent);
793
- const [sourceWidth, setSourceWidth] = useState(initial.sourceWidth);
794
- const [detailsWidth, setDetailsWidth] = useState(initial.detailsWidth);
795
- const [sourceCollapsed, setSourceCollapsed] = useState(initial.sourceCollapsed);
796
- const [detailsCollapsed, setDetailsCollapsed] = useState(initial.detailsCollapsed);
797
602
  const [navigatorFiltersOpen, setNavigatorFiltersOpen] = useState(false);
798
603
  const [favoritesOnly, setFavoritesOnly] = useState(false);
799
604
  const [runtimeReadyOnly, setRuntimeReadyOnly] = useState(false);
@@ -819,37 +624,30 @@ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserP
819
624
  source,
820
625
  assetType,
821
626
  query: searchQuery,
822
- density,
823
627
  sort,
824
628
  favorites: [...favorites],
825
629
  savedSearches,
826
630
  recent,
827
- sourceWidth,
828
- detailsWidth,
829
- sourceCollapsed,
830
- detailsCollapsed,
831
631
  } satisfies PersistedLibraryState),
832
632
  );
833
- }, [
834
- assetType,
835
- density,
836
- detailsCollapsed,
837
- detailsWidth,
838
- favorites,
839
- recent,
840
- savedSearches,
841
- searchQuery,
842
- sort,
843
- source,
844
- sourceCollapsed,
845
- sourceWidth,
846
- ]);
633
+ }, [assetType, favorites, recent, savedSearches, searchQuery, sort, source]);
847
634
 
848
635
  useEffect(() => {
849
636
  loadDownloadedAssets();
850
637
  return onAssetDownloaded(() => setAssets((current) => [...current]));
851
638
  }, []);
852
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
+
853
651
  useEffect(() => {
854
652
  if (source === 'polyhaven' || source === 'local') {
855
653
  getAssetLibraryCategories(assetType, source)
@@ -892,6 +690,7 @@ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserP
892
690
  const timer = window.setTimeout(() => {
893
691
  setOffset(0);
894
692
  setSelectedId(null);
693
+ if (getSelectedAsset()?.origin === 'library') clearSelectedAsset();
895
694
  void doSearch(0, false);
896
695
  }, 250);
897
696
  return () => window.clearTimeout(timer);
@@ -951,9 +750,23 @@ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserP
951
750
  health: count(assets.map((asset) => asset.capabilities?.health ?? 'unsupported')),
952
751
  };
953
752
  }, [assets]);
954
- const selected = assets.find((asset) => asset.id === selectedId) ?? null;
955
-
956
- 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
+ };
957
770
  const onOpen = (asset: OnlineAsset) => {
958
771
  openOnlineAssetDocument(store, toOnlineInfo(asset));
959
772
  const key = onlineAssetKey(asset);
@@ -964,15 +777,6 @@ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserP
964
777
  setAssetType(SOURCES.find((entry) => entry.value === nextSource)!.types[0]!.value);
965
778
  setCategory('');
966
779
  };
967
- const toggleFavorite = (asset: OnlineAsset) => {
968
- const key = onlineAssetKey(asset);
969
- setFavorites((current) => {
970
- const next = new Set(current);
971
- if (next.has(key)) next.delete(key);
972
- else next.add(key);
973
- return next;
974
- });
975
- };
976
780
  const sourcePane = (
977
781
  <SourcePane
978
782
  source={source}
@@ -1043,178 +847,5 @@ export function OnlineAssetBrowser({ layout = 'workspace' }: OnlineAssetBrowserP
1043
847
  />
1044
848
  );
1045
849
 
1046
- const workspaceLayout = (
1047
- <div
1048
- className="vgai-asset-library"
1049
- data-density={density}
1050
- data-source-collapsed={sourceCollapsed}
1051
- data-details-collapsed={detailsCollapsed}
1052
- style={
1053
- {
1054
- '--asset-source-width': `${sourceCollapsed ? 0 : sourceWidth}px`,
1055
- '--asset-details-width': `${detailsCollapsed ? 0 : detailsWidth}px`,
1056
- } as React.CSSProperties
1057
- }
1058
- >
1059
- {sourcePane}
1060
- <main className="vgai-asset-library__results">
1061
- <div className="vgai-asset-library__toolbar">
1062
- <Button
1063
- className="vgai-asset-library__filters-toggle"
1064
- variant="ghost"
1065
- size="comfortable"
1066
- onClick={() => setSourceCollapsed((value) => !value)}
1067
- >
1068
- {sourceCollapsed ? 'Show filters' : 'Hide filters'}
1069
- </Button>
1070
- <TextInput
1071
- className="vgai-input"
1072
- aria-label="Search asset library"
1073
- placeholder="Search models, packs, authors, clips…"
1074
- value={searchQuery}
1075
- onChange={(event) => setSearchQuery(event.target.value)}
1076
- style={{ flex: '1 1 220px' }}
1077
- />
1078
- <Select
1079
- className="vgai-select vgai-asset-library__sort"
1080
- aria-label="Sort assets"
1081
- value={sort}
1082
- onChange={(event) => setSort(event.target.value as Sort)}
1083
- >
1084
- <option value="relevance">Relevance</option>
1085
- <option value="name">Name</option>
1086
- <option value="source">Source</option>
1087
- <option value="size">Size</option>
1088
- <option value="recently-added">Recently added</option>
1089
- <option value="recently-used">Recently used</option>
1090
- </Select>
1091
- <Select
1092
- className="vgai-select vgai-asset-library__density"
1093
- aria-label="Card density"
1094
- value={density}
1095
- onChange={(event) => setDensity(event.target.value as Density)}
1096
- >
1097
- <option value="compact">Compact</option>
1098
- <option value="standard">Standard</option>
1099
- <option value="large">Large</option>
1100
- </Select>
1101
- <Button
1102
- className="vgai-asset-library__save-search"
1103
- variant="ghost"
1104
- size="comfortable"
1105
- disabled={!searchQuery || savedSearches.includes(searchQuery)}
1106
- onClick={() => setSavedSearches((current) => [...current, searchQuery])}
1107
- >
1108
- Save search
1109
- </Button>
1110
- <Button
1111
- className="vgai-asset-library__details-toggle"
1112
- variant="ghost"
1113
- size="comfortable"
1114
- onClick={() => setDetailsCollapsed((value) => !value)}
1115
- >
1116
- {detailsCollapsed ? 'Show details' : 'Hide details'}
1117
- </Button>
1118
- <span
1119
- className="vgai-asset-library__status"
1120
- role="status"
1121
- aria-live="polite"
1122
- style={{ fontSize: 11, color: text[3] }}
1123
- >
1124
- {filteredAssets.length} loaded · {total.toLocaleString()} total
1125
- </span>
1126
- </div>
1127
- {error ? (
1128
- <div role="alert" style={{ padding: 32, color: text[2], textAlign: 'center' }}>
1129
- <div>{error}</div>
1130
- <Button
1131
- variant="ghost"
1132
- size="comfortable"
1133
- onClick={() => {
1134
- setOffset(0);
1135
- void doSearch(0, false);
1136
- }}
1137
- >
1138
- Retry
1139
- </Button>
1140
- </div>
1141
- ) : filteredAssets.length === 0 && !loading ? (
1142
- <div role="status" style={{ padding: 32, color: text[3], textAlign: 'center' }}>
1143
- No assets match the current search and filters.
1144
- </div>
1145
- ) : (
1146
- <VirtualAssetGrid
1147
- assets={filteredAssets}
1148
- selectedId={selectedId}
1149
- density={density}
1150
- loading={loading}
1151
- hasMore={assets.length < total}
1152
- isFavorite={(asset) => favorites.has(onlineAssetKey(asset))}
1153
- onSelect={onSelect}
1154
- onOpen={onOpen}
1155
- onLoadMore={loadMore}
1156
- />
1157
- )}
1158
- </main>
1159
- <aside className="vgai-asset-library__details" aria-label="Selected asset details">
1160
- <div className="vgai-asset-pane-size">
1161
- <label>
1162
- Detail width
1163
- <TextInput
1164
- aria-label="Detail pane width"
1165
- type="range"
1166
- min="280"
1167
- max="640"
1168
- value={detailsWidth}
1169
- onChange={(event) => setDetailsWidth(Number(event.target.value))}
1170
- />
1171
- </label>
1172
- </div>
1173
- {selected ? (
1174
- <>
1175
- <div
1176
- style={{
1177
- display: 'flex',
1178
- gap: 8,
1179
- padding: '8px 10px',
1180
- borderBottom: '1px solid var(--vgai-border-1)',
1181
- }}
1182
- >
1183
- <Button variant="solid" size="comfortable" onClick={() => onOpen(selected)}>
1184
- Open in Asset Editor
1185
- </Button>
1186
- <Button variant="ghost" size="comfortable" onClick={() => toggleFavorite(selected)}>
1187
- {favorites.has(onlineAssetKey(selected)) ? 'Remove favorite' : 'Add favorite'}
1188
- </Button>
1189
- {getDownloadedAssetPath(selected.source, selected.id) && (
1190
- <span className="vgai-asset-badge">
1191
- <EditorIcon icon={faCheck} /> In project
1192
- </span>
1193
- )}
1194
- </div>
1195
- <OnlineAssetDetail
1196
- online={toOnlineInfo(selected)}
1197
- onResolved={() => setAssets((current) => [...current])}
1198
- />
1199
- </>
1200
- ) : (
1201
- <div role="status" style={{ padding: 24, color: text[3] }}>
1202
- Select an asset to inspect its preview, variants, license, and project status.
1203
- </div>
1204
- )}
1205
- </aside>
1206
- {!sourceCollapsed && (
1207
- <TextInput
1208
- className="vgai-asset-source-size"
1209
- aria-label="Source pane width"
1210
- type="range"
1211
- min="176"
1212
- max="360"
1213
- value={sourceWidth}
1214
- onChange={(event) => setSourceWidth(Number(event.target.value))}
1215
- />
1216
- )}
1217
- </div>
1218
- );
1219
- return { navigator: navigatorLayout, workspace: workspaceLayout }[layout];
850
+ return navigatorLayout;
1220
851
  }
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  type DockviewApi,
3
3
  DockviewDefaultTab,
4
- type DockviewPanelApi,
5
4
  DockviewReact,
6
5
  type DockviewTheme,
7
6
  type IDockviewPanelHeaderProps,
@@ -116,16 +115,6 @@ export const vgaiDockviewTheme: DockviewTheme = {
116
115
  tabAnimation: 'smooth',
117
116
  };
118
117
 
119
- function usePanelActive(api: DockviewPanelApi): boolean {
120
- const [active, setActive] = useState(api.isActive);
121
- useEffect(() => {
122
- setActive(api.isActive);
123
- const disposable = api.onDidActiveChange((event) => setActive(event.isActive));
124
- return () => disposable.dispose();
125
- }, [api]);
126
- return active;
127
- }
128
-
129
118
  function StaticPanelContent({ staticKind }: { staticKind: StaticPanelKind }) {
130
119
  const onOpenScene = useContext(OpenSceneContext);
131
120
  switch (staticKind) {
@@ -145,7 +134,6 @@ function StaticPanelContent({ staticKind }: { staticKind: StaticPanelKind }) {
145
134
  }
146
135
 
147
136
  function WorkspacePanel({ api, params }: IDockviewPanelProps<WorkspacePanelParams>) {
148
- const active = usePanelActive(api);
149
137
  useSyncExternalStore(
150
138
  subscribeWorkspaceDocuments,
151
139
  workspaceDocumentRegistryVersion,
@@ -192,6 +180,12 @@ function WorkspacePanel({ api, params }: IDockviewPanelProps<WorkspacePanelParam
192
180
  if (!document) return <div className="vgai-dock-empty">Restoring document…</div>;
193
181
  const Content = document.descriptor.Content;
194
182
  const Toolbar = document.descriptor.Toolbar;
183
+ // A bottom utility can own keyboard focus while the center document stays
184
+ // visibly selected in its own group. Dockview's `api.isActive` is global
185
+ // focus, not document activation; using it here suspended previews and
186
+ // cleared their Inspector/Hierarchy context whenever Console or Controls
187
+ // opened. The document registry is the semantic center-tab authority.
188
+ const active = activeWorkspaceDocumentId() === params.documentId;
195
189
  return (
196
190
  <div
197
191
  className="vgai-dock-document"