From abf901418b6ff7445b486fae042c41372f4acd3f Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 23:04:47 +0800 Subject: [PATCH] feat(web): add tabletop scene controls Disable auto-rotate and enable panning on the setup view, add a fullscreen toggle, and convert the controls to iconify icon buttons. --- .../src/components/tabletop/TabletopScene.tsx | 27 +++++++-- apps/web/src/components/viewers/Scene.tsx | 56 +++++++++++++++++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/tabletop/TabletopScene.tsx b/apps/web/src/components/tabletop/TabletopScene.tsx index 5a5fac5..734716f 100644 --- a/apps/web/src/components/tabletop/TabletopScene.tsx +++ b/apps/web/src/components/tabletop/TabletopScene.tsx @@ -1,4 +1,5 @@ -import { Suspense, useMemo } from 'react'; +import { Suspense, useMemo, useState } from 'react'; +import { Icon } from '@iconify/react'; import type { SerializedPackage, Setup } from '@tts/bgm'; import { SetupLoader, @@ -27,6 +28,7 @@ export default function TabletopScene({ }) { const packageData = useMemo(() => serializedToPackage(pkg), [pkg]); const surfaces = useTabletopStore((s) => s.surfaces); + const [showSurface, setShowSurface] = useState(false); const tree = useMemo( () => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))), @@ -34,14 +36,31 @@ export default function TabletopScene({ ); return ( - + setShowSurface((v) => !v)} + title={showSurface ? 'Hide surfaces' : 'Show surfaces'} + aria-label={showSurface ? 'Hide surfaces' : 'Show surfaces'} + className="absolute right-2 top-10 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100" + > + + + } + > {tree.world.map((node) => ( - + ))} {tree.hud.map((node) => ( - + ))} diff --git a/apps/web/src/components/viewers/Scene.tsx b/apps/web/src/components/viewers/Scene.tsx index 111a209..fc826d7 100644 --- a/apps/web/src/components/viewers/Scene.tsx +++ b/apps/web/src/components/viewers/Scene.tsx @@ -1,4 +1,5 @@ -import { Suspense, type ReactNode } from 'react'; +import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react'; +import { Icon } from '@iconify/react'; import { Canvas } from '@react-three/fiber'; import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei'; import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; @@ -12,11 +13,56 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; * The camera is fitted to the bounds of the content on mount. `Bounds` sits * inside the Suspense boundary, so it only mounts once the (suspending) content * has loaded and its geometry is present. + * + * `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for + * example, pans instead of rotating). `fullscreen` adds a toggle button that + * expands the scene to the full screen. */ -export default function Scene({ children }: { children: ReactNode }) { +export default function Scene({ + children, + autoRotate = true, + enablePan = false, + fullscreen = false, + overlay, +}: { + children: ReactNode; + autoRotate?: boolean; + enablePan?: boolean; + fullscreen?: boolean; + /** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */ + overlay?: ReactNode; +}) { + const containerRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const onChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggleFullscreen = () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else void containerRef.current?.requestFullscreen(); + }; + return ( -
+
+ {fullscreen && ( + + )} + {overlay}