1012 lines
38 KiB
TypeScript
1012 lines
38 KiB
TypeScript
'use client'
|
|
|
|
import { Icon } from '@iconify/react'
|
|
import {
|
|
type AnyNodeId,
|
|
type BuildingNode,
|
|
type GuideNode,
|
|
type LevelNode,
|
|
type ScanNode,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
|
|
import { useCallback, useRef, useState } from 'react'
|
|
import { useShallow } from 'zustand/react/shallow'
|
|
import { getLevelDisplayName } from '../../../lib/level-name'
|
|
import { createLocalGuideImage } from '../../../lib/local-guide-image'
|
|
import { cn } from '../../../lib/utils'
|
|
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
|
import { useUploadStore } from '../../../store/use-upload'
|
|
import { SliderControl } from '../controls/slider-control'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
|
import { ActionButton } from './action-button'
|
|
|
|
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
|
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
|
|
|
function formatGridSnapStep(step: GridSnapStep) {
|
|
return step.toFixed(2)
|
|
}
|
|
|
|
// ── Helper: get guide images for the current level ──────────────────────────
|
|
|
|
function useLevelGuides(): GuideNode[] {
|
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
return useScene(
|
|
useShallow((state) => {
|
|
if (!levelId) return [] as GuideNode[]
|
|
const level = state.nodes[levelId]
|
|
if (!level || level.type !== 'level') return [] as GuideNode[]
|
|
return (level as LevelNode).children
|
|
.map((id) => state.nodes[id])
|
|
.filter((node): node is GuideNode => node?.type === 'guide')
|
|
}),
|
|
)
|
|
}
|
|
|
|
// ── Helper: get scans for the current level ─────────────────────────────────
|
|
|
|
function useLevelScans(): ScanNode[] {
|
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
return useScene(
|
|
useShallow((state) => {
|
|
if (!levelId) return [] as ScanNode[]
|
|
const level = state.nodes[levelId]
|
|
if (!level || level.type !== 'level') return [] as ScanNode[]
|
|
return (level as LevelNode).children
|
|
.map((id) => state.nodes[id])
|
|
.filter((node): node is ScanNode => node?.type === 'scan')
|
|
}),
|
|
)
|
|
}
|
|
|
|
function useLowerReferenceLevels(): LevelNode[] {
|
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
return useScene(
|
|
useShallow((state) => {
|
|
if (!levelId) return [] as LevelNode[]
|
|
const activeLevel = state.nodes[levelId]
|
|
if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[]
|
|
const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined
|
|
const building = buildingId ? state.nodes[buildingId] : null
|
|
if (!building || building.type !== 'building') return [] as LevelNode[]
|
|
|
|
return (building.children ?? [])
|
|
.map((id) => state.nodes[id])
|
|
.filter(
|
|
(node): node is LevelNode =>
|
|
node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level,
|
|
)
|
|
.sort((a, b) => b.level - a.level)
|
|
}),
|
|
)
|
|
}
|
|
|
|
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
|
|
|
function UploadButton({ onError }: { onError: (message: string | null) => void }) {
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
const setShowGuides = useViewer((s) => s.setShowGuides)
|
|
const createNode = useScene((s) => s.createNode)
|
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
|
const [isAddingGuide, setIsAddingGuide] = useState(false)
|
|
|
|
const handleFileChange = useCallback(
|
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!(file && levelId)) return
|
|
e.target.value = ''
|
|
|
|
onError(null)
|
|
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
onError('File is too large. Maximum size is 200 MB.')
|
|
return
|
|
}
|
|
|
|
const isScan =
|
|
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
|
const isImage = file.type.startsWith('image/')
|
|
if (!(isScan || isImage)) {
|
|
onError('Upload a .glb/.gltf scan or an image.')
|
|
return
|
|
}
|
|
|
|
if (isImage) {
|
|
setIsAddingGuide(true)
|
|
try {
|
|
const guide = await createLocalGuideImage({ createNode, file, levelId })
|
|
setShowGuides(true)
|
|
setSelectedReferenceId(guide.id)
|
|
setSelection({ selectedIds: [], zoneId: null })
|
|
} catch {
|
|
onError('Could not add that guide image.')
|
|
} finally {
|
|
setIsAddingGuide(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
const { uploadHandler } = useUploadStore.getState()
|
|
if (!uploadHandler) {
|
|
onError('Scan upload is unavailable.')
|
|
return
|
|
}
|
|
|
|
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
|
|
if (!projectId) {
|
|
onError('Open a project before uploading a scan.')
|
|
return
|
|
}
|
|
|
|
useUploadStore.getState().clearUpload(levelId)
|
|
uploadHandler(projectId, levelId, file, 'scan')
|
|
},
|
|
[createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
aria-label="Upload scan or guide image"
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
|
disabled={isAddingGuide}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
type="button"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
<input
|
|
accept={ACCEPTED_FILE_TYPES}
|
|
className="hidden"
|
|
onChange={handleFileChange}
|
|
ref={fileInputRef}
|
|
type="file"
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── Guides toggle + dropdown ────────────────────────────────────────────────
|
|
|
|
function GuidesControl() {
|
|
const showGuides = useViewer((state) => state.showGuides)
|
|
const setShowGuides = useViewer((state) => state.setShowGuides)
|
|
const setSelection = useViewer((state) => state.setSelection)
|
|
const updateNode = useScene((state) => state.updateNode)
|
|
const deleteNode = useScene((state) => state.deleteNode)
|
|
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
|
|
const guides = useLevelGuides()
|
|
const hasGuides = guides.length > 0
|
|
|
|
const handleOpacityChange = useCallback(
|
|
(guideId: GuideNode['id'], opacity: number) => {
|
|
updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
|
|
},
|
|
[updateNode],
|
|
)
|
|
|
|
const handleSelectGuide = useCallback(
|
|
(guideId: GuideNode['id']) => {
|
|
setShowGuides(true)
|
|
setSelectedReferenceId(guideId)
|
|
setSelection({ selectedIds: [], zoneId: null })
|
|
},
|
|
[setSelectedReferenceId, setSelection, setShowGuides],
|
|
)
|
|
|
|
return (
|
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
<div className="flex items-center">
|
|
{/* Toggle button */}
|
|
<ActionButton
|
|
className={cn(
|
|
'rounded-r-none p-0',
|
|
showGuides
|
|
? 'bg-white/15'
|
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
)}
|
|
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
|
onClick={() => setShowGuides(!showGuides)}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<div className="relative">
|
|
<img
|
|
alt="Guides"
|
|
className="h-[28px] w-[28px] object-contain"
|
|
src="/icons/floorplan.png"
|
|
/>
|
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
|
{guides.length}
|
|
</span>
|
|
</div>
|
|
</ActionButton>
|
|
|
|
{/* Dropdown chevron */}
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-expanded={isOpen}
|
|
aria-label="Guide image settings"
|
|
className={cn(
|
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
showGuides
|
|
? isOpen
|
|
? 'bg-white/10'
|
|
: 'bg-white/5 hover:bg-white/8'
|
|
: isOpen
|
|
? 'bg-white/8'
|
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
)}
|
|
type="button"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
</div>
|
|
|
|
<PopoverContent
|
|
align="center"
|
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
|
|
side="top"
|
|
sideOffset={14}
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-foreground text-sm">Guide images</p>
|
|
{hasGuides && (
|
|
<p className="text-muted-foreground text-xs">
|
|
{guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level
|
|
</p>
|
|
)}
|
|
</div>
|
|
<UploadButton onError={setUploadError} />
|
|
</div>
|
|
|
|
{uploadError && (
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
|
|
{uploadError}
|
|
</div>
|
|
)}
|
|
|
|
{hasGuides ? (
|
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
|
{guides.map((guide, index) => (
|
|
<div
|
|
className={cn(
|
|
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
|
|
selectedReferenceId === guide.id
|
|
? 'border-foreground/35 bg-white/10'
|
|
: 'border-border/45',
|
|
)}
|
|
key={guide.id}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<button
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
onClick={() => handleSelectGuide(guide.id)}
|
|
type="button"
|
|
>
|
|
<img
|
|
alt=""
|
|
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
src="/icons/floorplan.png"
|
|
/>
|
|
<p className="truncate font-medium text-foreground text-sm">
|
|
{guide.name || `Guide image ${index + 1}`}
|
|
</p>
|
|
{selectedReferenceId === guide.id && (
|
|
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
|
|
)}
|
|
</button>
|
|
<button
|
|
aria-label="Delete guide image"
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
deleteNode(guide.id)
|
|
if (selectedReferenceId === guide.id) {
|
|
setSelectedReferenceId(null)
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<SliderControl
|
|
label="Opacity"
|
|
max={100}
|
|
min={0}
|
|
onChange={(value) => handleOpacityChange(guide.id, value)}
|
|
precision={0}
|
|
step={1}
|
|
unit="%"
|
|
value={guide.opacity}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
No guide images on this level yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// ── Grid snap toggle ────────────────────────────────────────────────────────
|
|
|
|
function GridSnapControl() {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
|
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
|
|
|
|
return (
|
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-expanded={isOpen}
|
|
aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
|
|
className={cn(
|
|
'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
|
|
isOpen && 'bg-white/10 text-foreground',
|
|
)}
|
|
type="button"
|
|
>
|
|
<Icon height={16} icon="lucide:grid-2x2" width={16} />
|
|
<span className="mt-1 font-medium text-[9px] leading-none">
|
|
{formatGridSnapStep(gridSnapStep)}
|
|
</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<PopoverContent
|
|
align="center"
|
|
className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
|
|
side="top"
|
|
sideOffset={14}
|
|
>
|
|
<div className="space-y-1">
|
|
{GRID_SNAP_STEPS.map((step) => {
|
|
const isActive = step === gridSnapStep
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
|
isActive && 'bg-white/10 text-foreground',
|
|
)}
|
|
key={step}
|
|
onClick={() => {
|
|
setGridSnapStep(step)
|
|
setIsOpen(false)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>{formatGridSnapStep(step)}</span>
|
|
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
|
|
|
function ScansControl() {
|
|
const showScans = useViewer((state) => state.showScans)
|
|
const setShowScans = useViewer((state) => state.setShowScans)
|
|
const setSelection = useViewer((state) => state.setSelection)
|
|
const updateNode = useScene((state) => state.updateNode)
|
|
const deleteNode = useScene((state) => state.deleteNode)
|
|
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
|
|
const scans = useLevelScans()
|
|
const hasScans = scans.length > 0
|
|
|
|
const handleOpacityChange = useCallback(
|
|
(scanId: ScanNode['id'], opacity: number) => {
|
|
updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
|
|
},
|
|
[updateNode],
|
|
)
|
|
|
|
const handleSelectScan = useCallback(
|
|
(scanId: ScanNode['id']) => {
|
|
setShowScans(true)
|
|
setSelectedReferenceId(scanId)
|
|
setSelection({ selectedIds: [], zoneId: null })
|
|
},
|
|
[setSelectedReferenceId, setSelection, setShowScans],
|
|
)
|
|
|
|
return (
|
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
<div className="flex items-center">
|
|
{/* Toggle button */}
|
|
<ActionButton
|
|
className={cn(
|
|
'rounded-r-none p-0',
|
|
showScans
|
|
? 'bg-white/15'
|
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
)}
|
|
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
|
onClick={() => setShowScans(!showScans)}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<div className="relative">
|
|
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
|
{scans.length}
|
|
</span>
|
|
</div>
|
|
</ActionButton>
|
|
|
|
{/* Dropdown chevron */}
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-expanded={isOpen}
|
|
aria-label="Scan settings"
|
|
className={cn(
|
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
showScans
|
|
? isOpen
|
|
? 'bg-white/10'
|
|
: 'bg-white/5 hover:bg-white/8'
|
|
: isOpen
|
|
? 'bg-white/8'
|
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
)}
|
|
type="button"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
</div>
|
|
|
|
<PopoverContent
|
|
align="center"
|
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
|
|
side="top"
|
|
sideOffset={14}
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-foreground text-sm">Scans</p>
|
|
{hasScans && (
|
|
<p className="text-muted-foreground text-xs">
|
|
{scans.length} scan{scans.length !== 1 ? 's' : ''} on this level
|
|
</p>
|
|
)}
|
|
</div>
|
|
<UploadButton onError={setUploadError} />
|
|
</div>
|
|
|
|
{uploadError && (
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
|
|
{uploadError}
|
|
</div>
|
|
)}
|
|
|
|
{hasScans ? (
|
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
|
{scans.map((scan, index) => (
|
|
<div
|
|
className={cn(
|
|
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
|
|
selectedReferenceId === scan.id
|
|
? 'border-foreground/35 bg-white/10'
|
|
: 'border-border/45',
|
|
)}
|
|
key={scan.id}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<button
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
onClick={() => handleSelectScan(scan.id)}
|
|
type="button"
|
|
>
|
|
<img
|
|
alt=""
|
|
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
src="/icons/mesh.png"
|
|
/>
|
|
<p className="truncate font-medium text-foreground text-sm">
|
|
{scan.name || `Scan ${index + 1}`}
|
|
</p>
|
|
{selectedReferenceId === scan.id && (
|
|
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
|
|
)}
|
|
</button>
|
|
<button
|
|
aria-label="Delete scan"
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
deleteNode(scan.id)
|
|
if (selectedReferenceId === scan.id) {
|
|
setSelectedReferenceId(null)
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<SliderControl
|
|
label="Opacity"
|
|
max={100}
|
|
min={0}
|
|
onChange={(value) => handleOpacityChange(scan.id, value)}
|
|
precision={0}
|
|
step={1}
|
|
unit="%"
|
|
value={scan.opacity}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
No scans on this level yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// ── References (merged scans + guides) ──────────────────────────────────────
|
|
// Bottom-bar control that folds the separate Scans and Guides toggles into one
|
|
// "References" split button + a popover holding both, each with its own
|
|
// visibility toggle, upload, and per-item opacity/delete.
|
|
|
|
function ReferenceListSection({
|
|
title,
|
|
iconSrc,
|
|
noun,
|
|
emptyText,
|
|
nodes,
|
|
show,
|
|
setShow,
|
|
onError,
|
|
}: {
|
|
title: string
|
|
iconSrc: string
|
|
noun: string
|
|
emptyText: string
|
|
nodes: (GuideNode | ScanNode)[]
|
|
show: boolean
|
|
setShow: (show: boolean) => void
|
|
onError: (message: string | null) => void
|
|
}) {
|
|
const setSelection = useViewer((state) => state.setSelection)
|
|
const updateNode = useScene((state) => state.updateNode)
|
|
const deleteNode = useScene((state) => state.deleteNode)
|
|
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
const hasItems = nodes.length > 0
|
|
|
|
const handleSelect = useCallback(
|
|
(id: AnyNodeId) => {
|
|
setShow(true)
|
|
setSelectedReferenceId(id)
|
|
setSelection({ selectedIds: [], zoneId: null })
|
|
},
|
|
[setShow, setSelectedReferenceId, setSelection],
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
<img alt="" className="h-4 w-4 object-contain" src={iconSrc} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-foreground text-sm">{title}</p>
|
|
{hasItems && (
|
|
<p className="text-muted-foreground text-xs">
|
|
{nodes.length} {noun}
|
|
{nodes.length !== 1 ? 's' : ''} on this level
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
aria-label={show ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
|
onClick={() => setShow(!show)}
|
|
type="button"
|
|
>
|
|
{show ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
</button>
|
|
<UploadButton onError={onError} />
|
|
</div>
|
|
|
|
{hasItems ? (
|
|
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
|
|
{nodes.map((node, index) => (
|
|
<div
|
|
className={cn(
|
|
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
|
|
selectedReferenceId === node.id
|
|
? 'border-foreground/35 bg-white/10'
|
|
: 'border-border/45',
|
|
)}
|
|
key={node.id}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<button
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
onClick={() => handleSelect(node.id)}
|
|
type="button"
|
|
>
|
|
<img
|
|
alt=""
|
|
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
src={iconSrc}
|
|
/>
|
|
<p className="truncate font-medium text-foreground text-sm">
|
|
{node.name || `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${index + 1}`}
|
|
</p>
|
|
{selectedReferenceId === node.id && (
|
|
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
|
|
)}
|
|
</button>
|
|
<button
|
|
aria-label={`Delete ${noun}`}
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
deleteNode(node.id)
|
|
if (selectedReferenceId === node.id) {
|
|
setSelectedReferenceId(null)
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<SliderControl
|
|
label="Opacity"
|
|
max={100}
|
|
min={0}
|
|
onChange={(value) =>
|
|
updateNode(node.id, { opacity: Math.round(Math.min(100, Math.max(0, value))) })
|
|
}
|
|
precision={0}
|
|
step={1}
|
|
unit="%"
|
|
value={node.opacity}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-3 text-muted-foreground text-sm">
|
|
{emptyText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReferencesControl() {
|
|
const showScans = useViewer((state) => state.showScans)
|
|
const setShowScans = useViewer((state) => state.setShowScans)
|
|
const showGuides = useViewer((state) => state.showGuides)
|
|
const setShowGuides = useViewer((state) => state.setShowGuides)
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
|
|
const scans = useLevelScans()
|
|
const guides = useLevelGuides()
|
|
const total = scans.length + guides.length
|
|
const anyVisible = showScans || showGuides
|
|
|
|
const toggleAll = useCallback(() => {
|
|
const next = !anyVisible
|
|
setShowScans(next)
|
|
setShowGuides(next)
|
|
}, [anyVisible, setShowScans, setShowGuides])
|
|
|
|
return (
|
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
<div className="flex items-center">
|
|
<ActionButton
|
|
className={cn(
|
|
'rounded-r-none p-0',
|
|
anyVisible
|
|
? 'bg-white/15'
|
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
)}
|
|
label={`References: ${anyVisible ? 'Visible' : 'Hidden'}`}
|
|
onClick={toggleAll}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<div className="relative">
|
|
<img
|
|
alt="References"
|
|
className="h-[28px] w-[28px] object-contain"
|
|
src="/icons/floorplan.png"
|
|
/>
|
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
|
{total}
|
|
</span>
|
|
</div>
|
|
</ActionButton>
|
|
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-expanded={isOpen}
|
|
aria-label="Reference settings"
|
|
className={cn(
|
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
anyVisible
|
|
? isOpen
|
|
? 'bg-white/10'
|
|
: 'bg-white/5 hover:bg-white/8'
|
|
: isOpen
|
|
? 'bg-white/8'
|
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
)}
|
|
type="button"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
</div>
|
|
|
|
<PopoverContent
|
|
align="center"
|
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
|
|
side="top"
|
|
sideOffset={14}
|
|
>
|
|
<div className="space-y-3">
|
|
{uploadError && (
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
|
|
{uploadError}
|
|
</div>
|
|
)}
|
|
<ReferenceListSection
|
|
emptyText="No scans on this level yet."
|
|
iconSrc="/icons/mesh.png"
|
|
nodes={scans}
|
|
noun="scan"
|
|
onError={setUploadError}
|
|
setShow={setShowScans}
|
|
show={showScans}
|
|
title="Scans"
|
|
/>
|
|
<div className="h-px bg-border/45" />
|
|
<ReferenceListSection
|
|
emptyText="No guide images on this level yet."
|
|
iconSrc="/icons/floorplan.png"
|
|
nodes={guides}
|
|
noun="guide image"
|
|
onError={setUploadError}
|
|
setShow={setShowGuides}
|
|
show={showGuides}
|
|
title="Guide images"
|
|
/>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// ── Reference floor control ────────────────────────────────────────────────────────────────────
|
|
|
|
function ReferenceFloorControl() {
|
|
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
|
|
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
|
|
const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset)
|
|
const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset)
|
|
const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity)
|
|
const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity)
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const lowerLevels = useLowerReferenceLevels()
|
|
const hasLowerLevels = lowerLevels.length > 0
|
|
const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null
|
|
const selectedLevelName = selectedLevel ? getLevelDisplayName(selectedLevel) : null
|
|
|
|
return (
|
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
<div className="flex items-center">
|
|
<ActionButton
|
|
className={cn(
|
|
'rounded-r-none p-0',
|
|
showReferenceFloor && selectedLevel
|
|
? 'bg-white/15'
|
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
)}
|
|
disabled={!hasLowerLevels}
|
|
label={
|
|
selectedLevelName && showReferenceFloor
|
|
? `Reference floor: ${selectedLevelName}`
|
|
: 'Reference floor'
|
|
}
|
|
onClick={() => {
|
|
if (hasLowerLevels) toggleReferenceFloor()
|
|
}}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<div className="relative">
|
|
<Layers2 className="h-4 w-4" />
|
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
|
{lowerLevels.length}
|
|
</span>
|
|
</div>
|
|
</ActionButton>
|
|
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-expanded={isOpen}
|
|
aria-label="Reference floor settings"
|
|
className={cn(
|
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
showReferenceFloor && selectedLevel
|
|
? isOpen
|
|
? 'bg-white/10'
|
|
: 'bg-white/5 hover:bg-white/8'
|
|
: isOpen
|
|
? 'bg-white/8'
|
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
)}
|
|
disabled={!hasLowerLevels}
|
|
type="button"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
</div>
|
|
|
|
<PopoverContent
|
|
align="center"
|
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
|
side="top"
|
|
sideOffset={14}
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
<Layers2 className="h-4 w-4" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-foreground text-sm">Reference floor</p>
|
|
{selectedLevelName && (
|
|
<p className="truncate text-muted-foreground text-xs">{selectedLevelName}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
disabled={!hasLowerLevels}
|
|
onClick={toggleReferenceFloor}
|
|
type="button"
|
|
>
|
|
{showReferenceFloor ? (
|
|
<Eye className="h-3.5 w-3.5" />
|
|
) : (
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{hasLowerLevels ? (
|
|
<>
|
|
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-border/45 bg-background/60 p-1.5">
|
|
{lowerLevels.map((level, index) => {
|
|
const isSelected = referenceFloorOffset === index + 1
|
|
const levelName = getLevelDisplayName(level)
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
|
isSelected && showReferenceFloor && 'bg-white/10 text-foreground',
|
|
)}
|
|
key={level.id}
|
|
onClick={() => {
|
|
setReferenceFloorOffset(index + 1)
|
|
if (!showReferenceFloor) {
|
|
toggleReferenceFloor()
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
<span
|
|
className={cn(
|
|
'h-3.5 w-3.5 rounded-full border',
|
|
isSelected && showReferenceFloor
|
|
? 'border-foreground bg-foreground'
|
|
: 'border-muted-foreground/35',
|
|
)}
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate">{levelName}</span>
|
|
<span className="text-[10px] text-muted-foreground">{index + 1} below</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<SliderControl
|
|
label="Opacity"
|
|
max={0.8}
|
|
min={0.1}
|
|
onChange={setReferenceFloorOpacity}
|
|
precision={2}
|
|
step={0.05}
|
|
value={referenceFloorOpacity}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
No lower floor available.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
|
|
export { GridSnapControl }
|
|
|
|
export function SecondaryToggles() {
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<ReferencesControl />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ViewToggles() {
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<GridSnapControl />
|
|
<ScansControl />
|
|
<GuidesControl />
|
|
<ReferenceFloorControl />
|
|
</div>
|
|
)
|
|
}
|