mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-14 23:53:14 +00:00
Single-object viewer POC (#14)
This commit implements a Sketchfab-style single-object viewer as a minimal proof-of-concept. ### `@storyteller/studio-web` This is a new package to house web components for interfacing with the `@storyteller/studio` WASM library. It currently includes a single component, `<sts-viewer />`, which is exported from `@storyteller/studio-web/viewer`. ### `react-frontend` This is a new application intended as a proof-of-concept and testbed for React interop with `@storyteller/studio-web`. Web components are currently a little bit fiddly to use in a React application. `react-frontend/src/app/studio-viewer.tsx` demonstrates how a React-idiomatic component can be written as a thin wrapper around a web component, and `react-frontend/src/app/app.tsx` demonstrates that wrapper component in use.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #2222227F;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 32px;
|
||||
font: unset {
|
||||
family: inherit;
|
||||
size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #222;
|
||||
pointer-events: none;
|
||||
transition: opacity 66.666ms linear;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { type SceneStateEvent, SceneState } from "@storyteller/studio";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import StudioViewer from "./studio-viewer";
|
||||
|
||||
import style from "./app.module.scss";
|
||||
|
||||
const OBJECTS = [
|
||||
{
|
||||
id: "bookcase",
|
||||
label: "Bookcase",
|
||||
},
|
||||
{
|
||||
id: "couch",
|
||||
label: "Couch",
|
||||
},
|
||||
{
|
||||
id: "desk-chair",
|
||||
label: "Desk Chair",
|
||||
},
|
||||
{
|
||||
id: "desk",
|
||||
label: "Desk",
|
||||
},
|
||||
{
|
||||
id: "file-cabinet",
|
||||
label: "File Cabinet",
|
||||
},
|
||||
{
|
||||
id: "potted-plant-1",
|
||||
label: "Potted Plant #1",
|
||||
},
|
||||
{
|
||||
id: "potted-plant-3",
|
||||
label: "Potted Plant #2",
|
||||
},
|
||||
{
|
||||
id: "room",
|
||||
label: "Room (empty)",
|
||||
},
|
||||
{
|
||||
id: "sample-room",
|
||||
label: "Room (populated)",
|
||||
},
|
||||
{
|
||||
id: "rug",
|
||||
label: "Rug",
|
||||
},
|
||||
{
|
||||
id: "wicker-basket",
|
||||
label: "Wicker Basket",
|
||||
},
|
||||
];
|
||||
const SKYBOXES = [
|
||||
{
|
||||
id: "gum_trees_4k",
|
||||
label: "Gum Trees",
|
||||
},
|
||||
{
|
||||
id: "kloofendal_28d_misty_4k",
|
||||
label: "Kloofendal Misty",
|
||||
},
|
||||
{
|
||||
id: "meadow_4k",
|
||||
label: "Meadow",
|
||||
},
|
||||
{
|
||||
id: "promenade_de_vidy_4k",
|
||||
label: "Promenade de Vidy",
|
||||
},
|
||||
{
|
||||
id: "scythian_tombs_4k",
|
||||
label: "Scythian Tombs",
|
||||
},
|
||||
{
|
||||
id: "test_scene",
|
||||
label: "Desert Sunrise",
|
||||
},
|
||||
];
|
||||
|
||||
export default () => {
|
||||
const [objectId, setObjectId] = useState("desk");
|
||||
const [skyboxId, setSkyboxId] = useState("test_scene");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const onObjectSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
setObjectId((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
const onSkyboxSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
setSkyboxId((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
const onSceneStateChange = ({ detail: state }: SceneStateEvent) => {
|
||||
setLoading(state !== SceneState.Active);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.root}>
|
||||
<StudioViewer
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
onSceneStateChange={onSceneStateChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={style.loading}
|
||||
style={{
|
||||
opacity: loading ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
|
||||
<div className={style.controls}>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Object</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="object"
|
||||
value={objectId}
|
||||
disabled={loading}
|
||||
onChange={onObjectSelect}
|
||||
>
|
||||
{OBJECTS.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Skybox</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="skybox"
|
||||
value={skyboxId}
|
||||
disabled={loading}
|
||||
onChange={onSkyboxSelect}
|
||||
>
|
||||
{SKYBOXES.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SceneStateEvent } from "@storyteller/studio";
|
||||
import { ViewerElement } from "@storyteller/studio-web/viewer";
|
||||
import "@storyteller/studio-web/viewer";
|
||||
import { MutableRefObject, forwardRef, useEffect, useRef } from "react";
|
||||
|
||||
export interface Props {
|
||||
objectId: string;
|
||||
skyboxId: string;
|
||||
onSceneStateChange?(event: SceneStateEvent): void;
|
||||
}
|
||||
|
||||
export default forwardRef<ViewerElement, Props>(({
|
||||
objectId,
|
||||
skyboxId,
|
||||
onSceneStateChange,
|
||||
}, ref) => {
|
||||
const viewerRef = useRef<ViewerElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
|
||||
if (viewer && onSceneStateChange) {
|
||||
viewer.addEventListener("scene-state", onSceneStateChange);
|
||||
return () => {
|
||||
viewer.removeEventListener("scene-state", onSceneStateChange);
|
||||
}
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const refCallback = (viewer: ViewerElement) => {
|
||||
(viewerRef as MutableRefObject<ViewerElement>).current = viewer;
|
||||
|
||||
if (typeof ref === "function")
|
||||
ref(viewer);
|
||||
else if (ref != null)
|
||||
ref.current = viewer;
|
||||
}
|
||||
|
||||
return (
|
||||
<sts-viewer
|
||||
ref={refCallback}
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./app/app";
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
root.render(<App />);
|
||||
@@ -0,0 +1,8 @@
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
import { ViewerElement } from "@storyteller/studio-web";
|
||||
|
||||
declare global {
|
||||
export namespace JSX {
|
||||
export interface IntrinsicElements {
|
||||
// FIXME: This causes `<sts-viewer />` to be recognized as an HTML
|
||||
// element, but it doesn't carry over its `@property`s
|
||||
"sts-viewer": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<ViewerElement>,
|
||||
ViewerElement,
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user