feat(viewer): redesign viewer UI with dark toolbar and camera controls (#126)
Overhaul viewer overlay with a unified bottom toolbar, dark theme support, theme toggle, camera orbit/top-view controls, and fix TypeScript array access errors for level and wall mode cycling. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
98c1d5247e
commit
bde8a005a7
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@@ -105,6 +105,54 @@ export const ViewerCameraControls = () => {
|
|||||||
)
|
)
|
||||||
}, [targetNodeId, nodes])
|
}, [targetNodeId, nodes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTopView = () => {
|
||||||
|
if (!controls.current) return
|
||||||
|
|
||||||
|
const currentPolarAngle = controls.current.polarAngle
|
||||||
|
|
||||||
|
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
|
||||||
|
// Otherwise, go to top view (0°)
|
||||||
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
||||||
|
|
||||||
|
controls.current.rotatePolarTo(targetAngle, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOrbitCW = () => {
|
||||||
|
if (!controls.current) return
|
||||||
|
|
||||||
|
const currentAzimuth = controls.current.azimuthAngle
|
||||||
|
const currentPolar = controls.current.polarAngle
|
||||||
|
// Round to nearest 90° increment, then rotate 90° clockwise
|
||||||
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||||
|
const target = rounded - Math.PI / 2
|
||||||
|
|
||||||
|
controls.current.rotateTo(target, currentPolar, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOrbitCCW = () => {
|
||||||
|
if (!controls.current) return
|
||||||
|
|
||||||
|
const currentAzimuth = controls.current.azimuthAngle
|
||||||
|
const currentPolar = controls.current.polarAngle
|
||||||
|
// Round to nearest 90° increment, then rotate 90° counter-clockwise
|
||||||
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||||
|
const target = rounded + Math.PI / 2
|
||||||
|
|
||||||
|
controls.current.rotateTo(target, currentPolar, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('camera-controls:top-view', handleTopView)
|
||||||
|
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
|
||||||
|
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('camera-controls:top-view', handleTopView)
|
||||||
|
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
||||||
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
const onTransitionStart = useCallback(() => {
|
||||||
useViewer.getState().setCameraDragging(true)
|
useViewer.getState().setCameraDragging(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export function ViewerGuestCTA() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
|
<div className="absolute top-4 right-4 z-20 dark text-foreground">
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-xl rounded-smooth-xl px-6 py-3 shadow-[0_4px_16px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.03)] flex items-center gap-4">
|
<div className="pointer-events-auto bg-background/95 backdrop-blur-xl border border-border/40 rounded-2xl px-6 py-3 shadow-lg transition-colors duration-200 ease-out flex flex-col sm:flex-row items-center gap-4">
|
||||||
<p className="text-sm text-neutral-700">Want to create your own 3D project?</p>
|
<p className="text-sm font-medium text-foreground text-center">Want to create your own 3D project?</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSignIn(true)}
|
onClick={() => setShowSignIn(true)}
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap"
|
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,17 +11,46 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Box,
|
Camera,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Diamond,
|
Diamond,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Image,
|
|
||||||
Layers,
|
Layers,
|
||||||
Layers2,
|
Layers2,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||||
|
import { ActionButton } from '@/components/ui/action-menu/action-button'
|
||||||
|
import { TooltipProvider } from '@/components/ui/primitives/tooltip'
|
||||||
|
import { emitter } from '@pascal-app/core'
|
||||||
|
|
||||||
|
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||||
|
stacked: 'Stacked',
|
||||||
|
exploded: 'Exploded',
|
||||||
|
solo: 'Solo',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallModeConfig = {
|
||||||
|
up: {
|
||||||
|
icon: (props: any) => (
|
||||||
|
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
|
||||||
|
),
|
||||||
|
label: 'Full Height',
|
||||||
|
},
|
||||||
|
cutaway: {
|
||||||
|
icon: (props: any) => (
|
||||||
|
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
|
||||||
|
),
|
||||||
|
label: 'Cutaway',
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
icon: (props: any) => <img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />,
|
||||||
|
label: 'Low',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const getNodeName = (node: AnyNode): string => {
|
const getNodeName = (node: AnyNode): string => {
|
||||||
if ('name' in node && node.name) return node.name
|
if ('name' in node && node.name) return node.name
|
||||||
@@ -53,6 +82,7 @@ export const ViewerOverlay = ({
|
|||||||
const cameraMode = useViewer((s) => s.cameraMode)
|
const cameraMode = useViewer((s) => s.cameraMode)
|
||||||
const levelMode = useViewer((s) => s.levelMode)
|
const levelMode = useViewer((s) => s.levelMode)
|
||||||
const wallMode = useViewer((s) => s.wallMode)
|
const wallMode = useViewer((s) => s.wallMode)
|
||||||
|
const theme = useViewer((s) => s.theme)
|
||||||
|
|
||||||
const building = selection.buildingId
|
const building = selection.buildingId
|
||||||
? (nodes[selection.buildingId] as BuildingNode | undefined)
|
? (nodes[selection.buildingId] as BuildingNode | undefined)
|
||||||
@@ -95,24 +125,24 @@ export const ViewerOverlay = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Unified top-left card */}
|
{/* Unified top-left card */}
|
||||||
<div className="absolute top-4 left-4 z-20 flex flex-col gap-3">
|
<div className="absolute top-4 left-4 z-20 flex flex-col gap-3 dark text-foreground">
|
||||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg rounded-smooth shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] overflow-hidden">
|
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden min-w-[200px]">
|
||||||
{/* Project info + back */}
|
{/* Project info + back */}
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 transition-colors"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 text-neutral-500" />
|
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">
|
<div className="text-sm font-medium text-foreground truncate">
|
||||||
{projectName || 'Untitled'}
|
{projectName || 'Untitled'}
|
||||||
</div>
|
</div>
|
||||||
{owner?.username && (
|
{owner?.username && (
|
||||||
<Link
|
<Link
|
||||||
href={`/u/${owner.username}`}
|
href={`/u/${owner.username}`}
|
||||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@{owner.username}
|
@{owner.username}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -122,21 +152,21 @@ export const ViewerOverlay = ({
|
|||||||
|
|
||||||
{/* Breadcrumb — only shown when navigated into a building */}
|
{/* Breadcrumb — only shown when navigated into a building */}
|
||||||
{building && (
|
{building && (
|
||||||
<div className="border-t border-neutral-100 px-3 py-1.5">
|
<div className="border-t border-border/40 px-3 py-2">
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('root')}
|
onClick={() => handleBreadcrumbClick('root')}
|
||||||
className="text-neutral-500 hover:text-neutral-800 transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
Site
|
Site
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{building && (
|
{building && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('building')}
|
onClick={() => handleBreadcrumbClick('building')}
|
||||||
className={`transition-colors truncate ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
className={`transition-colors truncate ${level ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
|
||||||
>
|
>
|
||||||
{building.name || 'Building'}
|
{building.name || 'Building'}
|
||||||
</button>
|
</button>
|
||||||
@@ -145,10 +175,10 @@ export const ViewerOverlay = ({
|
|||||||
|
|
||||||
{level && (
|
{level && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('level')}
|
onClick={() => handleBreadcrumbClick('level')}
|
||||||
className={`transition-colors truncate ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
className={`transition-colors truncate ${zone ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
|
||||||
>
|
>
|
||||||
{level.name || `Level ${level.level}`}
|
{level.name || `Level ${level.level}`}
|
||||||
</button>
|
</button>
|
||||||
@@ -157,9 +187,9 @@ export const ViewerOverlay = ({
|
|||||||
|
|
||||||
{zone && (
|
{zone && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||||
<span
|
<span
|
||||||
className={`transition-colors truncate ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}
|
className={`transition-colors truncate ${selectedNode ? 'text-muted-foreground' : 'text-foreground font-medium'}`}
|
||||||
>
|
>
|
||||||
{zone.name}
|
{zone.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -168,8 +198,8 @@ export const ViewerOverlay = ({
|
|||||||
|
|
||||||
{selectedNode && zone && (
|
{selectedNode && zone && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||||
<span className="text-neutral-800 font-medium truncate">
|
<span className="text-foreground font-medium truncate">
|
||||||
{getNodeName(selectedNode)}
|
{getNodeName(selectedNode)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -181,161 +211,212 @@ export const ViewerOverlay = ({
|
|||||||
|
|
||||||
{/* Level List (only when building is selected) */}
|
{/* Level List (only when building is selected) */}
|
||||||
{building && levels.length > 0 && (
|
{building && levels.length > 0 && (
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] w-40">
|
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden w-48 py-1">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-3 py-2">Levels</span>
|
||||||
{levels.map((lvl) => (
|
<div className="flex flex-col">
|
||||||
<button
|
{levels.map((lvl) => {
|
||||||
key={lvl.id}
|
const isSelected = lvl.id === selection.levelId;
|
||||||
onClick={() => handleLevelClick(lvl.id)}
|
return (
|
||||||
className={`text-left px-2 py-1 rounded text-sm transition-colors ${
|
<button
|
||||||
lvl.id === selection.levelId
|
key={lvl.id}
|
||||||
? 'bg-blue-500 text-white'
|
onClick={() => handleLevelClick(lvl.id)}
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
className={cn(
|
||||||
}`}
|
"relative flex items-center h-8 w-full cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200 px-3",
|
||||||
>
|
isSelected
|
||||||
{lvl.name || `Level ${lvl.level}`}
|
? "bg-accent/50 text-foreground border-r-white border-r-3"
|
||||||
</button>
|
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||||
))}
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span className={cn(
|
||||||
|
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
|
||||||
|
!isSelected && "opacity-60 grayscale"
|
||||||
|
)}>
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0 truncate text-left">
|
||||||
|
{lvl.name || `Level ${lvl.level}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls Panel - Top Right */}
|
{/* Controls Panel - Bottom Center */}
|
||||||
<div className="absolute top-4 right-4 z-20 flex flex-col gap-2">
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground">
|
||||||
{/* Visibility Controls */}
|
<TooltipProvider delayDuration={0}>
|
||||||
{(canShowScans || canShowGuides) && (
|
<div className="pointer-events-auto flex flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out h-14">
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
{/* Theme Toggle */}
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
|
<button
|
||||||
|
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer h-[36px]"
|
||||||
|
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<div className="relative flex">
|
||||||
|
{/* Sliding Background */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
x: theme === "light" ? "100%" : "0%",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 500,
|
||||||
|
damping: 35,
|
||||||
|
}}
|
||||||
|
style={{ width: "50%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dark Mode Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||||
|
theme === "dark"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light Mode Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||||
|
theme === "light"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* Scans and Guides Visibility */}
|
||||||
{canShowScans && (
|
{canShowScans && (
|
||||||
<button
|
<ActionButton
|
||||||
|
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||||
|
tooltipSide="top"
|
||||||
|
className={showScans ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
|
||||||
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
size="icon"
|
||||||
showScans ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
variant="ghost"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Box className="w-4 h-4" />
|
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||||
3D Scans
|
</ActionButton>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canShowGuides && (
|
{canShowGuides && (
|
||||||
<button
|
<ActionButton
|
||||||
|
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||||
|
tooltipSide="top"
|
||||||
|
className={showGuides ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
|
||||||
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
size="icon"
|
||||||
showGuides ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
variant="ghost"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Image className="w-4 h-4" />
|
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
|
||||||
Guides
|
</ActionButton>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
|
||||||
|
|
||||||
|
{/* Camera Mode */}
|
||||||
|
<ActionButton
|
||||||
|
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||||
|
tooltipSide="top"
|
||||||
|
className={cameraMode === 'orthographic' ? 'bg-violet-500/20 text-violet-400' : 'hover:text-violet-400 hover:bg-white/5'}
|
||||||
|
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Camera className="h-6 w-6" />
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
{/* Level Mode */}
|
||||||
|
<ActionButton
|
||||||
|
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||||
|
tooltipSide="top"
|
||||||
|
className={levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400 hover:bg-white/5'}
|
||||||
|
onClick={() => {
|
||||||
|
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
|
||||||
|
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
||||||
|
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
|
||||||
|
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||||
|
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||||
|
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
{/* Wall Mode */}
|
||||||
|
<ActionButton
|
||||||
|
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
||||||
|
tooltipSide="top"
|
||||||
|
className={wallMode !== 'cutaway' ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
|
||||||
|
onClick={() => {
|
||||||
|
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
|
||||||
|
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
|
||||||
|
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
|
||||||
|
return <Icon className="h-[28px] w-[28px]" />
|
||||||
|
})()}
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* Camera Actions */}
|
||||||
|
<ActionButton
|
||||||
|
label="Orbit Left"
|
||||||
|
tooltipSide="top"
|
||||||
|
className="group hover:bg-white/5 hidden sm:inline-flex"
|
||||||
|
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<img alt="Orbit Left" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100" src="/icons/rotate.png" />
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
label="Orbit Right"
|
||||||
|
tooltipSide="top"
|
||||||
|
className="group hover:bg-white/5 hidden sm:inline-flex"
|
||||||
|
onClick={() => emitter.emit('camera-controls:orbit-cw')}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<img alt="Orbit Right" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/rotate.png" />
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
label="Top View"
|
||||||
|
tooltipSide="top"
|
||||||
|
className="group hover:bg-white/5"
|
||||||
|
onClick={() => emitter.emit('camera-controls:top-view')}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<img alt="Top View" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/topview.png" />
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Camera Mode */}
|
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
useViewer
|
|
||||||
.getState()
|
|
||||||
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded text-sm text-neutral-700 hover:bg-neutral-100 transition-colors"
|
|
||||||
>
|
|
||||||
{cameraMode === 'perspective' ? (
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Level Mode */}
|
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setLevelMode('stacked')}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
levelMode === 'stacked'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Layers className="w-4 h-4" />
|
|
||||||
Stacked
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setLevelMode('exploded')}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
levelMode === 'exploded'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Layers2 className="w-4 h-4" />
|
|
||||||
Exploded
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setLevelMode('solo')}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
levelMode === 'solo'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Diamond className="w-4 h-4" />
|
|
||||||
Solo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wall Mode */}
|
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setWallMode('cutaway')}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
wallMode === 'cutaway'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Cutaway"
|
|
||||||
height={16}
|
|
||||||
src="/icons/wallcut.png"
|
|
||||||
width={16}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
Cutaway
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setWallMode('up')}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
wallMode === 'up' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Full Height"
|
|
||||||
height={16}
|
|
||||||
src="/icons/room.png"
|
|
||||||
width={16}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
Full Height
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => useViewer.getState().setWallMode('down')}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
|
||||||
wallMode === 'down'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-neutral-700 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img alt="Low" height={16} src="/icons/walllow.png" width={16} className="w-4 h-4" />
|
|
||||||
Low
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
|||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
tooltipContent?: React.ReactNode;
|
tooltipContent?: React.ReactNode;
|
||||||
|
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||||
(
|
(
|
||||||
{ className, children, label, shortcut, isActive, tooltipContent, ...props },
|
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +48,7 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side={tooltipSide}>
|
||||||
{tooltipContent || (
|
{tooltipContent || (
|
||||||
<p>
|
<p>
|
||||||
{label} {shortcut && `(${shortcut})`}
|
{label} {shortcut && `(${shortcut})`}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const useViewer = create<ViewerState>()(
|
|||||||
levelMode: "stacked",
|
levelMode: "stacked",
|
||||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||||
|
|
||||||
wallMode: 'cutaway',
|
wallMode: 'up',
|
||||||
setWallMode: (mode) => set({ wallMode: mode }),
|
setWallMode: (mode) => set({ wallMode: mode }),
|
||||||
|
|
||||||
showScans: true,
|
showScans: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user