diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index cd272ded..acd9f0cb 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -46,11 +46,6 @@ const SELECTION_STRATEGIES: Record = { structure: { types: ["wall", "item", "zone", "slab", "ceiling", "roof"], handleSelect: (node, isShift) => { - // Single click on item (door/window) → enter move mode - if (!isShift && node.type === 'item') { - useEditor.getState().setMovingNode(node as ItemNode); - return; - } const { selection, setSelection } = useViewer.getState(); if (node.type === 'zone') { setSelection({ zoneId: node.id }); @@ -93,11 +88,6 @@ const SELECTION_STRATEGIES: Record = { furnish: { types: ["item"], handleSelect: (node, isShift) => { - // Single click on item → enter move mode - if (!isShift && node.type === 'item') { - useEditor.getState().setMovingNode(node as ItemNode); - return; - } const { selection, setSelection } = useViewer.getState(); const nextIds = isShift ? selection.selectedIds.includes(node.id) diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx new file mode 100644 index 00000000..50f9165a --- /dev/null +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -0,0 +1,196 @@ +'use client' + +import { type AnyNode, type ItemNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2, X } from 'lucide-react' +import Image from 'next/image' +import { useCallback } from 'react' +import useEditor from '@/store/use-editor' +import { NumberInput } from '@/components/ui/primitives/number-input' + +export function ItemPanel() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + const nodes = useScene((s) => s.nodes) + const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + // Get the first selected node if it's an item + const selectedId = selectedIds[0] + const node = selectedId + ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) + : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const handleMove = useCallback(() => { + if (node) { + setMovingNode(node) + // Deselect so the panel closes + setSelection({ selectedIds: [] }) + } + }, [node, setMovingNode, setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId) return + deleteNode(selectedId as AnyNode['id']) + setSelection({ selectedIds: [] }) + }, [selectedId, deleteNode, setSelection]) + + // Only show if exactly one item is selected + if (!node || node.type !== 'item' || selectedIds.length !== 1) return null + + return ( +
+ {/* Header */} +
+
+ +

+ {node.name || node.asset.name} +

+
+ +
+ + {/* Content */} +
+
+ {/* Position */} +
+ +
+ { + handleUpdate({ position: [value, node.position[1], node.position[2]] }) + }} + precision={2} + /> + { + handleUpdate({ position: [node.position[0], value, node.position[2]] }) + }} + precision={2} + /> + { + handleUpdate({ position: [node.position[0], node.position[1], value] }) + }} + precision={2} + /> +
+
+ + {/* Rotation */} +
+ +
+ { + const radians = (degrees * Math.PI) / 180 + handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) + }} + precision={0} + className="flex-1" + /> + ° +
+
+ + +
+
+ + {/* Dimensions (read-only) */} +
+ +
+ {node.asset.dimensions[0]}m × {node.asset.dimensions[1]}m × {node.asset.dimensions[2]}m +
+
+
+
+ + {/* Action Buttons */} +
+
+ + +
+
+
+ ) +} diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx index 92d26f68..f5a6951a 100644 --- a/apps/editor/components/ui/panels/panel-manager.tsx +++ b/apps/editor/components/ui/panels/panel-manager.tsx @@ -3,6 +3,7 @@ import { AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor from '@/store/use-editor' +import { ItemPanel } from './item-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { SlabPanel } from './slab-panel' @@ -23,6 +24,8 @@ export function PanelManager() { const node = nodes[selectedNode as AnyNodeId] if (node) { switch (node.type) { + case 'item': + return case 'roof': return case 'slab': diff --git a/apps/editor/components/ui/panels/reference-panel.tsx b/apps/editor/components/ui/panels/reference-panel.tsx index 3956e0e7..5bbc644e 100644 --- a/apps/editor/components/ui/panels/reference-panel.tsx +++ b/apps/editor/components/ui/panels/reference-panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-a import { Box, Image, X } from 'lucide-react' import { useCallback } from 'react' import useEditor from '@/store/use-editor' +import { NumberInput } from '@/components/ui/primitives/number-input' type ReferenceNode = ScanNode | GuideNode @@ -65,23 +66,17 @@ export function ReferencePanel() {
{([0, 1, 2] as const).map((i) => ( -
- - { - const value = Number.parseFloat(e.target.value) - if (!Number.isNaN(value)) { - const pos = [...node.position] as [number, number, number] - pos[i] = value - handleUpdate({ position: pos }) - } - }} - step="0.1" - type="number" - value={Math.round(node.position[i] * 100) / 100} - /> -
+ { + const pos = [...node.position] as [number, number, number] + pos[i] = value + handleUpdate({ position: pos }) + }} + precision={2} + /> ))}
@@ -92,20 +87,17 @@ export function ReferencePanel() { Rotation
- { - const degrees = Number.parseFloat(e.target.value) - if (!Number.isNaN(degrees)) { - const radians = (degrees * Math.PI) / 180 - handleUpdate({ - rotation: [node.rotation[0], radians, node.rotation[2]], - }) - } - }} - step="1" - type="number" + { + const radians = (degrees * Math.PI) / 180 + handleUpdate({ + rotation: [node.rotation[0], radians, node.rotation[2]], + }) + }} + precision={0} + className="min-w-0 flex-1" /> °
diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx index cd0c1f7e..1c126b77 100644 --- a/apps/editor/components/ui/panels/slab-panel.tsx +++ b/apps/editor/components/ui/panels/slab-panel.tsx @@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer' import { X } from 'lucide-react' import Image from 'next/image' import { useCallback } from 'react' +import { NumberInput } from '@/components/ui/primitives/number-input' export function SlabPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -76,17 +77,14 @@ export function SlabPanel() { Elevation
- { - const value = Number.parseFloat(e.target.value) - if (!Number.isNaN(value)) { - handleUpdate({ elevation: value }) - } - }} - step="0.05" - type="number" + { + handleUpdate({ elevation: value }) + }} + precision={3} + className="flex-1" /> m
diff --git a/apps/editor/components/ui/primitives/number-input.tsx b/apps/editor/components/ui/primitives/number-input.tsx new file mode 100644 index 00000000..89db024d --- /dev/null +++ b/apps/editor/components/ui/primitives/number-input.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useCallback, useRef, useState } from 'react' + +interface NumberInputProps { + label: string + value: number + onChange: (value: number) => void + min?: number + max?: number + precision?: number + className?: string +} + +export function NumberInput({ + label, + value, + onChange, + min, + max, + precision = 2, + className = '', +}: NumberInputProps) { + const [isEditing, setIsEditing] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [inputValue, setInputValue] = useState(value.toFixed(precision)) + const startXRef = useRef(0) + const startValueRef = useRef(0) + const labelRef = useRef(null) + + const clamp = useCallback( + (val: number) => { + if (min !== undefined && val < min) return min + if (max !== undefined && val > max) return max + return val + }, + [min, max], + ) + + const handleLabelMouseDown = useCallback( + (e: React.MouseEvent) => { + if (isEditing) return + e.preventDefault() + setIsDragging(true) + startXRef.current = e.clientX + startValueRef.current = value + + // Pause history tracking during drag + useScene.temporal.getState().pause() + + let finalValue = value + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startXRef.current + + // Determine step size based on modifier keys + let step = 0.1 // Default + if (moveEvent.shiftKey) { + step = 1.0 // Coarse + } else if (moveEvent.altKey) { + step = 0.01 // Fine + } + + const deltaValue = deltaX * step + const newValue = clamp(startValueRef.current + deltaValue) + finalValue = Number.parseFloat(newValue.toFixed(precision)) + onChange(finalValue) + } + + const handleMouseUp = () => { + setIsDragging(false) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + + // Resume history tracking and commit final value + useScene.temporal.getState().resume() + onChange(finalValue) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, + [isEditing, value, onChange, clamp, precision], + ) + + const handleValueClick = useCallback(() => { + setIsEditing(true) + setInputValue(value.toFixed(precision)) + }, [value, precision]) + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setInputValue(e.target.value) + }, []) + + const handleInputBlur = useCallback(() => { + const numValue = Number.parseFloat(inputValue) + if (!Number.isNaN(numValue)) { + onChange(clamp(Number.parseFloat(numValue.toFixed(precision)))) + } + setIsEditing(false) + }, [inputValue, onChange, clamp, precision]) + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const numValue = Number.parseFloat(inputValue) + if (!Number.isNaN(numValue)) { + onChange(clamp(Number.parseFloat(numValue.toFixed(precision)))) + } + setIsEditing(false) + } else if (e.key === 'Escape') { + setInputValue(value.toFixed(precision)) + setIsEditing(false) + } + }, + [inputValue, onChange, value, clamp, precision], + ) + + return ( +
+
+ + {isEditing ? ( + + ) : ( +
+ {value.toFixed(precision)} +
+ )} +
+
+ ) +}