feat(web): rework mod inspector layout
Fill the viewport with a fixed sidebar of highlightable type-filter icons over an independently scrolling tree, and let the viewer expand into a full-height scene.
This commit is contained in:
@@ -19,12 +19,17 @@ const SetupsPage = lazy(() => import('./pages/SetupsPage'));
|
|||||||
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { pathname } = useLocation();
|
const pathname = useLocation().pathname;
|
||||||
const isModRoute = pathname.startsWith('/mod/');
|
const isModRoute = pathname.startsWith('/mod/');
|
||||||
|
// The inspector view is full-bleed/viewport-height; the setup sub-route keeps the standard centered layout.
|
||||||
|
const isModView = /^\/mod\/[^/]+$/.test(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||||
<header className="border-b border-zinc-800">
|
{/* For the mod route the layout is full-bleed and fills the viewport so
|
||||||
|
the sidebar can scroll independently and the viewer gets all the space. */}
|
||||||
|
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
|
||||||
|
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
|
||||||
{isModRoute ? (
|
{isModRoute ? (
|
||||||
<ModHeader />
|
<ModHeader />
|
||||||
) : (
|
) : (
|
||||||
@@ -43,7 +48,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
<main className={(isModView ? "min-h-0 flex-1" : "mx-auto max-w-5xl px-4 py-8")}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
@@ -65,6 +70,7 @@ export default function App() {
|
|||||||
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,44 +32,51 @@ export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
|||||||
const clearAll = () => setHighlighted(new Set());
|
const clearAll = () => setHighlighted(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{types.length > 0 && (
|
{types.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 border-b border-zinc-800 pb-2">
|
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
|
||||||
{types.map(({ name, count }) => {
|
<div className="grid grid-cols-6 gap-1">
|
||||||
const active = highlighted.has(name);
|
{types.map(({ name, count }) => {
|
||||||
return (
|
const active = highlighted.has(name);
|
||||||
<button
|
return (
|
||||||
key={name}
|
<button
|
||||||
onClick={() => toggleType(name)}
|
key={name}
|
||||||
aria-pressed={active}
|
onClick={() => toggleType(name)}
|
||||||
title={`${active ? 'Unhighlight' : 'Highlight'} ${name}`}
|
aria-pressed={active}
|
||||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors ${
|
title={`${name} (${count}) — ${active ? 'unhighlight' : 'highlight'}`}
|
||||||
active
|
className={`relative flex h-8 items-center justify-center rounded-md border transition-colors ${
|
||||||
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
|
active
|
||||||
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
|
||||||
}`}
|
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||||
>
|
}`}
|
||||||
<span className="inline-flex items-center gap-0.5">
|
>
|
||||||
{iconsForObject(name).map((icon) => (
|
{iconsForObject(name).map((icon) => (
|
||||||
<Icon key={icon} icon={icon} className="h-3.5 w-3.5" />
|
<Icon key={icon} icon={icon} className="h-4 w-4" />
|
||||||
))}
|
))}
|
||||||
</span>
|
<span
|
||||||
<span>{name}</span>
|
className={`absolute -bottom-1 -right-1 rounded bg-zinc-950 px-0.5 font-mono text-[9px] leading-tight ${
|
||||||
<span className="text-zinc-500">{count}</span>
|
active ? 'text-zinc-200' : 'text-zinc-500'
|
||||||
</button>
|
}`}
|
||||||
);
|
>
|
||||||
})}
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{highlighted.size > 0 && (
|
{highlighted.size > 0 && (
|
||||||
<button
|
<div className="mt-1.5 flex justify-end">
|
||||||
onClick={clearAll}
|
<button
|
||||||
className="ml-auto inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
|
onClick={clearAll}
|
||||||
>
|
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
|
||||||
Clear
|
>
|
||||||
</button>
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-0.5">
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-3 pt-2">
|
||||||
{visibleNodes.map(({ node, path }) => (
|
{visibleNodes.map(({ node, path }) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
export interface ObjectViewer {
|
export interface ObjectViewer {
|
||||||
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
||||||
name: string;
|
name: string;
|
||||||
component: (props: { object: TTSObject }) => ReactNode;
|
component: (props: ViewerProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props passed to every object viewer. */
|
||||||
|
export interface ViewerProps {
|
||||||
|
object: TTSObject;
|
||||||
|
/** Expand the 3D scene to fill its container instead of the default frame. */
|
||||||
|
fill?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = new Map<string, ObjectViewer['component']>();
|
const registry = new Map<string, ObjectViewer['component']>();
|
||||||
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The default viewer: a plain inspection of the object's fields. */
|
/** The default viewer: a plain inspection of the object's fields. */
|
||||||
export function DefaultViewer({ object }: { object: TTSObject }) {
|
export function DefaultViewer({ object }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
{Object.entries(object).map(([key, value]) => {
|
{Object.entries(object).map(([key, value]) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { CardObjectMesh } from './CardMesh';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A playing card: a thin rounded rect with the face texture on the front and
|
* A playing card: a thin rounded rect with the face texture on the front and
|
||||||
@@ -24,9 +25,9 @@ import { CardObjectMesh } from './CardMesh';
|
|||||||
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
||||||
* the card.
|
* the card.
|
||||||
*/
|
*/
|
||||||
export default function CardViewer({ object }: { object: TTSObject }) {
|
export default function CardViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CardObjectMesh object={object} />
|
<CardObjectMesh object={object} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { CustomModelMesh } from './CustomModelMesh';
|
import { CustomModelMesh } from './CustomModelMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
@@ -8,9 +9,9 @@ import { CustomModelMesh } from './CustomModelMesh';
|
|||||||
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
||||||
* model's materials when present.
|
* model's materials when present.
|
||||||
*/
|
*/
|
||||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
export default function CustomModelViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CustomModelMesh object={object} />
|
<CustomModelMesh object={object} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
import { useBounds } from '@react-three/drei';
|
import { useBounds } from '@react-three/drei';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { CardObjectMesh } from './CardMesh';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/** How many cards to show to each side of the active card. */
|
/** How many cards to show to each side of the active card. */
|
||||||
const HALF_WINDOW = 3;
|
const HALF_WINDOW = 3;
|
||||||
@@ -29,7 +30,7 @@ const ARC_PADDING = 0.2;
|
|||||||
* hidden), so large decks stay lean. Falls back to a single card (the deck
|
* hidden), so large decks stay lean. Falls back to a single card (the deck
|
||||||
* object itself) when there are no contained cards.
|
* object itself) when there are no contained cards.
|
||||||
*/
|
*/
|
||||||
export default function DeckViewer({ object }: { object: TTSObject }) {
|
export default function DeckViewer({ object, fill }: ViewerProps) {
|
||||||
const cards = (object.ContainedObjects ?? []).filter(
|
const cards = (object.ContainedObjects ?? []).filter(
|
||||||
(o) => o.CardID != null || o.CustomImage != null,
|
(o) => o.CardID != null || o.CustomImage != null,
|
||||||
);
|
);
|
||||||
@@ -61,6 +62,7 @@ export default function DeckViewer({ object }: { object: TTSObject }) {
|
|||||||
<Scene
|
<Scene
|
||||||
fit={false}
|
fit={false}
|
||||||
autoRotate={false}
|
autoRotate={false}
|
||||||
|
fill={fill}
|
||||||
overlay={
|
overlay={
|
||||||
hasCards ? (
|
hasCards ? (
|
||||||
<CarouselControls active={active} count={count} onStep={step} />
|
<CarouselControls active={active} count={count} onStep={step} />
|
||||||
@@ -116,16 +118,27 @@ function FitActive({
|
|||||||
onMeasure: (width: number) => void;
|
onMeasure: (width: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const bounds = useBounds();
|
const bounds = useBounds();
|
||||||
|
// Whether the active card's width has been measured once. The fit waits one
|
||||||
|
// frame after measuring so the arc radius (and thus the active card's slot)
|
||||||
|
// has settled before framing it.
|
||||||
|
const measured = useRef(false);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const node = targetRef.current;
|
const node = targetRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
// Stop refreshing once fit: drei's `bounds.refresh()` resets the camera
|
||||||
|
// goal, so calling it every frame would wipe the fit we just set before
|
||||||
|
// the camera tween ever runs.
|
||||||
|
if (didFit.current) return;
|
||||||
bounds.refresh(node);
|
bounds.refresh(node);
|
||||||
const { size, center, distance } = bounds.getSize();
|
const { size, center, distance } = bounds.getSize();
|
||||||
// Report the active card's width so the arc radius can widen for wide
|
// Report the active card's width so the arc radius can widen for wide
|
||||||
// cards (see `arcRadius`). Runs every frame until the radius settles.
|
// cards (see `arcRadius`).
|
||||||
onMeasure(size.x);
|
onMeasure(size.x);
|
||||||
if (didFit.current) return;
|
if (!measured.current) {
|
||||||
|
measured.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
didFit.current = true;
|
didFit.current = true;
|
||||||
// The card's front face points toward +Z, so put the camera in front of it
|
// The card's front face points toward +Z, so put the camera in front of it
|
||||||
// and look straight at it.
|
// and look straight at it.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
|||||||
* content on mount and resize. A viewer that needs to frame a specific part of
|
* content on mount and resize. A viewer that needs to frame a specific part of
|
||||||
* its content (e.g. the active card in a deck carousel) can set it to false and
|
* its content (e.g. the active card in a deck carousel) can set it to false and
|
||||||
* call `useBounds()` itself to refit.
|
* call `useBounds()` itself to refit.
|
||||||
|
*
|
||||||
|
* By default the scene renders in an `aspect-[3/4]` frame; set `fill` to expand
|
||||||
|
* to the full height of its container (used by the inspector view).
|
||||||
*/
|
*/
|
||||||
export default function Scene({
|
export default function Scene({
|
||||||
children,
|
children,
|
||||||
@@ -31,6 +34,7 @@ export default function Scene({
|
|||||||
enablePan = false,
|
enablePan = false,
|
||||||
fullscreen = false,
|
fullscreen = false,
|
||||||
fit = true,
|
fit = true,
|
||||||
|
fill = false,
|
||||||
overlay,
|
overlay,
|
||||||
shadowScale = 22,
|
shadowScale = 22,
|
||||||
maxPolarAngle = Math.PI,
|
maxPolarAngle = Math.PI,
|
||||||
@@ -41,6 +45,8 @@ export default function Scene({
|
|||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
|
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
|
||||||
fit?: boolean;
|
fit?: boolean;
|
||||||
|
/** Expand to fill the container height instead of the default aspect-[3/4] frame. */
|
||||||
|
fill?: boolean;
|
||||||
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
@@ -65,7 +71,9 @@ export default function Scene({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative aspect-[3/4] w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400"
|
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
|
||||||
|
fill ? 'h-full min-h-0' : 'aspect-[3/4]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
{fullscreen && (
|
{fullscreen && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { TileObjectMesh } from './TileMesh';
|
import { TileObjectMesh } from './TileMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||||
@@ -10,9 +11,9 @@ import { TileObjectMesh } from './TileMesh';
|
|||||||
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
||||||
* source image instead of being forced square.
|
* source image instead of being forced square.
|
||||||
*/
|
*/
|
||||||
export default function TileViewer({ object }: { object: TTSObject }) {
|
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TileObjectMesh object={object} />
|
<TileObjectMesh object={object} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { TokenObjectMesh } from './TokenMesh';
|
import { TokenObjectMesh } from './TokenMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A token: a short extruded shape with the texture on its top face. Uses
|
* A token: a short extruded shape with the texture on its top face. Uses
|
||||||
@@ -8,9 +9,9 @@ import { TokenObjectMesh } from './TokenMesh';
|
|||||||
* color when absent. The footprint is traced from the image's alpha channel via
|
* color when absent. The footprint is traced from the image's alpha channel via
|
||||||
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
|
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
|
||||||
*/
|
*/
|
||||||
export default function TokenViewer({ object }: { object: TTSObject }) {
|
export default function TokenViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TokenObjectMesh object={object} />
|
<TokenObjectMesh object={object} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,59 +39,37 @@ export default function ModPage() {
|
|||||||
const Viewer = selected ? resolveViewer(selected.object) : null;
|
const Viewer = selected ? resolveViewer(selected.object) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex h-full min-h-0">
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
{/* Independently scrolling tree; the filter bar stays pinned above it. */}
|
||||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
|
<aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
|
||||||
<ObjectTree
|
<ObjectTree
|
||||||
nodes={tree}
|
nodes={tree}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
onSelect={setSelectedPath}
|
onSelect={setSelectedPath}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<section className="h-full min-h-0 flex-1 p-4">
|
||||||
{selected ? (
|
{selected && Viewer ? (
|
||||||
<>
|
/* Key by selection path so the Canvas remounts and the camera
|
||||||
<header className="mb-4 flex items-center gap-2">
|
refits to the newly selected object. */
|
||||||
<span
|
<ErrorBoundary>
|
||||||
title={selected.object.Name}
|
<Suspense
|
||||||
className="inline-flex shrink-0 items-center gap-1 text-zinc-400"
|
fallback={
|
||||||
>
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
{iconsForObject(selected.object.Name).map((icon) => (
|
Loading viewer…
|
||||||
<Icon key={icon} icon={icon} className="h-5 w-5" />
|
</div>
|
||||||
))}
|
}
|
||||||
</span>
|
>
|
||||||
<h2 className="min-w-0 flex-1 truncate text-lg font-semibold">
|
<Viewer key={selectedPath} object={selected.object} fill />
|
||||||
{selected.label}
|
</Suspense>
|
||||||
</h2>
|
</ErrorBoundary>
|
||||||
<span className="shrink-0 font-mono text-xs text-zinc-500">
|
) : (
|
||||||
{selected.object.GUID}
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
</span>
|
Select an object from the tree to inspect it.
|
||||||
</header>
|
</div>
|
||||||
|
)}
|
||||||
{Viewer && (
|
</section>
|
||||||
<ErrorBoundary>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
|
||||||
Loading viewer…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Key by selection path so the Canvas remounts and the
|
|
||||||
camera refits to the newly selected object. */}
|
|
||||||
<Viewer key={selectedPath} object={selected.object} />
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Select an object from the tree to inspect it.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user