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:
Danny McGee
2023-12-07 15:46:32 -05:00
committed by GitHub
parent 155272a5ba
commit cba70a62ae
70 changed files with 71793 additions and 1157 deletions
+46
View File
@@ -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;
}
+157
View File
@@ -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>
);
}
+46
View File
@@ -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}
/>
);
});
View File
+8
View File
@@ -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 />);
+8
View File
@@ -0,0 +1,8 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
font-family: "Fira Sans", sans-serif;
color-scheme: dark;
}
+14
View File
@@ -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,
>;
}
}
}