274 lines
7.2 KiB
TypeScript
Executable File
274 lines
7.2 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
type BuildingNode,
|
|
type CeilingNode,
|
|
type ColumnNode,
|
|
type DoorNode,
|
|
type FenceNode,
|
|
type ItemNode,
|
|
type RoofNode,
|
|
type RoofSegmentNode,
|
|
type SlabNode,
|
|
type StairNode,
|
|
type StairSegmentNode,
|
|
useScene,
|
|
type WallNode,
|
|
type WindowNode,
|
|
} from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
import useEditor from '../../../store/use-editor'
|
|
import { CeilingPanel } from './ceiling-panel'
|
|
import { ColumnPanel } from './column-panel'
|
|
import { DoorPanel } from './door-panel'
|
|
import { FencePanel } from './fence-panel'
|
|
import { ItemPanel } from './item-panel'
|
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
|
import { MobileSelectionBar } from './mobile-selection-bar'
|
|
import { getNodeDisplay } from './node-display'
|
|
import { PaintPanel } from './paint-panel'
|
|
import { ReferencePanel } from './reference-panel'
|
|
import { RoofPanel } from './roof-panel'
|
|
import { RoofSegmentPanel } from './roof-segment-panel'
|
|
import { SlabPanel } from './slab-panel'
|
|
import { SpawnPanel } from './spawn-panel'
|
|
import { StairPanel } from './stair-panel'
|
|
import { StairSegmentPanel } from './stair-segment-panel'
|
|
import { WallPanel } from './wall-panel'
|
|
import { WindowPanel } from './window-panel'
|
|
|
|
type MovableNode =
|
|
| ItemNode
|
|
| WindowNode
|
|
| DoorNode
|
|
| CeilingNode
|
|
| ColumnNode
|
|
| SlabNode
|
|
| WallNode
|
|
| FenceNode
|
|
| RoofNode
|
|
| RoofSegmentNode
|
|
| StairNode
|
|
| StairSegmentNode
|
|
| BuildingNode
|
|
|
|
const MOVABLE_TYPES = new Set<string>([
|
|
'item',
|
|
'window',
|
|
'door',
|
|
'ceiling',
|
|
'column',
|
|
'slab',
|
|
'wall',
|
|
'fence',
|
|
'roof',
|
|
'roof-segment',
|
|
'stair',
|
|
'stair-segment',
|
|
'building',
|
|
])
|
|
|
|
function isMovableNode(node: AnyNode | null): node is MovableNode {
|
|
return !!node && MOVABLE_TYPES.has(node.type)
|
|
}
|
|
|
|
function panelForType(type: string | null) {
|
|
if (!type) return null
|
|
switch (type) {
|
|
case 'item':
|
|
return <ItemPanel />
|
|
case 'roof':
|
|
return <RoofPanel />
|
|
case 'roof-segment':
|
|
return <RoofSegmentPanel />
|
|
case 'stair':
|
|
return <StairPanel />
|
|
case 'stair-segment':
|
|
return <StairSegmentPanel />
|
|
case 'slab':
|
|
return <SlabPanel />
|
|
case 'ceiling':
|
|
return <CeilingPanel />
|
|
case 'column':
|
|
return <ColumnPanel />
|
|
case 'wall':
|
|
return <WallPanel />
|
|
case 'fence':
|
|
return <FencePanel />
|
|
case 'door':
|
|
return <DoorPanel />
|
|
case 'window':
|
|
return <WindowPanel />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function MobilePanelLayer({
|
|
node,
|
|
panel,
|
|
isReference,
|
|
}: {
|
|
node: AnyNode | null
|
|
panel: React.ReactNode
|
|
isReference: boolean
|
|
}) {
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
|
|
|
// Reset sheet open state when the selection changes / clears
|
|
const selectionKey = node?.id ?? (isReference ? 'reference' : null)
|
|
useEffect(() => {
|
|
setIsSheetOpen(false)
|
|
}, [selectionKey])
|
|
|
|
const clearSelection = useCallback(() => {
|
|
setSelection({ selectedIds: [] })
|
|
setSelectedReferenceId(null)
|
|
}, [setSelection, setSelectedReferenceId])
|
|
|
|
const handleMove = useCallback(() => {
|
|
if (!isMovableNode(node)) return
|
|
sfxEmitter.emit('sfx:item-pick')
|
|
setMovingNode(node)
|
|
clearSelection()
|
|
}, [node, setMovingNode, clearSelection])
|
|
|
|
const handleDuplicate = useCallback(() => {
|
|
if (!isMovableNode(node)) return
|
|
sfxEmitter.emit('sfx:item-pick')
|
|
const cloned = structuredClone(node) as MovableNode & { id?: AnyNodeId }
|
|
delete (cloned as { id?: AnyNodeId }).id
|
|
const prevMeta =
|
|
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
|
? (cloned.metadata as Record<string, unknown>)
|
|
: {}
|
|
cloned.metadata = { ...prevMeta, isNew: true }
|
|
setMovingNode(cloned as MovableNode)
|
|
clearSelection()
|
|
}, [node, setMovingNode, clearSelection])
|
|
|
|
const handleDelete = useCallback(() => {
|
|
if (!node) return
|
|
sfxEmitter.emit('sfx:item-delete')
|
|
deleteNode(node.id)
|
|
clearSelection()
|
|
}, [node, deleteNode, clearSelection])
|
|
|
|
if (!(node || isReference)) return null
|
|
|
|
const display = getNodeDisplay(node)
|
|
|
|
return (
|
|
<>
|
|
{node && (
|
|
<MobileSelectionBar
|
|
node={node}
|
|
onDelete={handleDelete}
|
|
onDuplicate={handleDuplicate}
|
|
onEdit={() => setIsSheetOpen((v) => !v)}
|
|
onMove={handleMove}
|
|
/>
|
|
)}
|
|
<MobilePanelSheet
|
|
icon={display.icon}
|
|
onClose={() => setIsSheetOpen(false)}
|
|
open={isSheetOpen}
|
|
title={display.label}
|
|
>
|
|
{panel}
|
|
</MobilePanelSheet>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function PanelManager() {
|
|
const isMobile = useIsMobile()
|
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
|
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
|
const mode = useEditor((s) => s.mode)
|
|
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
|
// Only subscribe to the *type* of the single-selected node — string primitive
|
|
// so we don't re-render on unrelated scene mutations.
|
|
const selectedNodeType = useScene((s) => {
|
|
if (selectedIds.length !== 1) return null
|
|
const id = selectedIds[0]
|
|
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
|
|
})
|
|
const selectedNode = useScene((s) => {
|
|
if (selectedIds.length !== 1) return null
|
|
const id = selectedIds[0]
|
|
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
|
|
})
|
|
|
|
if (isMobile) {
|
|
if (selectedReferenceId) {
|
|
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
|
|
}
|
|
return (
|
|
<MobilePanelLayer
|
|
isReference={false}
|
|
node={selectedNode}
|
|
panel={panelForType(selectedNodeType)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Show reference panel if a reference is selected
|
|
if (selectedReferenceId) {
|
|
return <ReferencePanel />
|
|
}
|
|
|
|
if (
|
|
isPaintPanelOpen &&
|
|
mode === 'material-paint' &&
|
|
activePaintMaterial?.material?.properties &&
|
|
!activePaintMaterial.materialPreset
|
|
) {
|
|
return <PaintPanel />
|
|
}
|
|
|
|
// Show appropriate panel based on selected node type
|
|
if (selectedNodeType) {
|
|
switch (selectedNodeType) {
|
|
case 'item':
|
|
return <ItemPanel />
|
|
case 'roof':
|
|
return <RoofPanel />
|
|
case 'roof-segment':
|
|
return <RoofSegmentPanel />
|
|
case 'stair':
|
|
return <StairPanel />
|
|
case 'stair-segment':
|
|
return <StairSegmentPanel />
|
|
case 'slab':
|
|
return <SlabPanel />
|
|
case 'spawn':
|
|
return <SpawnPanel />
|
|
case 'ceiling':
|
|
return <CeilingPanel />
|
|
case 'column':
|
|
return <ColumnPanel />
|
|
case 'wall':
|
|
return <WallPanel />
|
|
case 'fence':
|
|
return <FencePanel />
|
|
case 'door':
|
|
return <DoorPanel />
|
|
case 'window':
|
|
return <WindowPanel />
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|