figma like tools

This commit is contained in:
wass08
2026-02-10 10:45:42 +09:00
parent 6ec6a55b05
commit daf41f7da5
6 changed files with 389 additions and 59 deletions
@@ -46,11 +46,6 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
structure: { structure: {
types: ["wall", "item", "zone", "slab", "ceiling", "roof"], types: ["wall", "item", "zone", "slab", "ceiling", "roof"],
handleSelect: (node, isShift) => { 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(); const { selection, setSelection } = useViewer.getState();
if (node.type === 'zone') { if (node.type === 'zone') {
setSelection({ zoneId: node.id }); setSelection({ zoneId: node.id });
@@ -93,11 +88,6 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
furnish: { furnish: {
types: ["item"], types: ["item"],
handleSelect: (node, isShift) => { 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 { selection, setSelection } = useViewer.getState();
const nextIds = isShift const nextIds = isShift
? selection.selectedIds.includes(node.id) ? selection.selectedIds.includes(node.id)
@@ -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<ItemNode>) => {
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 (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0">
<Image
src={node.asset.thumbnail || '/icons/furniture.png'}
alt=""
width={16}
height={16}
className="shrink-0 object-contain"
/>
<h2 className="font-semibold text-foreground text-sm truncate">
{node.name || node.asset.name}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
<NumberInput
label="X"
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => {
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}}
precision={2}
/>
<NumberInput
label="Y"
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => {
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}}
precision={2}
/>
<NumberInput
label="Z"
value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => {
handleUpdate({ position: [node.position[0], node.position[1], value] })
}}
precision={2}
/>
</div>
</div>
{/* Rotation */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<div className="flex items-center gap-2">
<NumberInput
label="Y"
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
precision={0}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">°</span>
</div>
<div className="flex gap-2">
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees - 90
const radians = (newDegrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
>
-90°
</button>
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees + 90
const radians = (newDegrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
>
+90°
</button>
</div>
</div>
{/* Dimensions (read-only) */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
{node.asset.dimensions[0]}m × {node.asset.dimensions[1]}m × {node.asset.dimensions[2]}m
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="border-t p-3">
<div className="flex gap-2">
<button
type="button"
className="flex-1 flex items-center justify-center gap-2 rounded border border-border bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 cursor-pointer"
onClick={handleMove}
>
<Move className="h-4 w-4" />
<span>Move</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-2 rounded border border-border bg-destructive px-4 py-2 text-destructive-foreground hover:bg-destructive/90 cursor-pointer"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</button>
</div>
</div>
</div>
)
}
@@ -3,6 +3,7 @@
import { AnyNodeId, useScene } from '@pascal-app/core' import { AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { ItemPanel } from './item-panel'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
import { SlabPanel } from './slab-panel' import { SlabPanel } from './slab-panel'
@@ -23,6 +24,8 @@ export function PanelManager() {
const node = nodes[selectedNode as AnyNodeId] const node = nodes[selectedNode as AnyNodeId]
if (node) { if (node) {
switch (node.type) { switch (node.type) {
case 'item':
return <ItemPanel />
case 'roof': case 'roof':
return <RoofPanel /> return <RoofPanel />
case 'slab': case 'slab':
@@ -4,6 +4,7 @@ import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-a
import { Box, Image, X } from 'lucide-react' import { Box, Image, X } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
type ReferenceNode = ScanNode | GuideNode type ReferenceNode = ScanNode | GuideNode
@@ -65,23 +66,17 @@ export function ReferencePanel() {
</label> </label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{([0, 1, 2] as const).map((i) => ( {([0, 1, 2] as const).map((i) => (
<div key={i} className="space-y-1"> <NumberInput
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label> key={i}
<input label={['X', 'Y', 'Z'][i]!}
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary" value={Math.round(node.position[i] * 100) / 100}
onChange={(e) => { onChange={(value) => {
const value = Number.parseFloat(e.target.value) const pos = [...node.position] as [number, number, number]
if (!Number.isNaN(value)) { pos[i] = value
const pos = [...node.position] as [number, number, number] handleUpdate({ position: pos })
pos[i] = value }}
handleUpdate({ position: pos }) precision={2}
} />
}}
step="0.1"
type="number"
value={Math.round(node.position[i] * 100) / 100}
/>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -92,20 +87,17 @@ export function ReferencePanel() {
Rotation Rotation
</label> </label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <NumberInput
className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary" label="Y"
onChange={(e) => {
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"
value={Math.round((node.rotation[1] * 180) / Math.PI)} value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({
rotation: [node.rotation[0], radians, node.rotation[2]],
})
}}
precision={0}
className="min-w-0 flex-1"
/> />
<span className="text-muted-foreground text-xs shrink-0">&deg;</span> <span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<button <button
@@ -136,18 +128,16 @@ export function ReferencePanel() {
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> <label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Scale Scale
</label> </label>
<input <NumberInput
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary" label="Scale"
min="0.01" value={Math.round(node.scale * 100) / 100}
onChange={(e) => { onChange={(value) => {
const value = Number.parseFloat(e.target.value) if (value > 0) {
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ scale: value }) handleUpdate({ scale: value })
} }
}} }}
step="0.1" min={0.01}
type="number" precision={2}
value={Math.round(node.scale * 100) / 100}
/> />
</div> </div>
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { NumberInput } from '@/components/ui/primitives/number-input'
export function SlabPanel() { export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -76,17 +77,14 @@ export function SlabPanel() {
Elevation Elevation
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <NumberInput
className="flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary" label="Elevation"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value)) {
handleUpdate({ elevation: value })
}
}}
step="0.05"
type="number"
value={Math.round(node.elevation * 1000) / 1000} value={Math.round(node.elevation * 1000) / 1000}
onChange={(value) => {
handleUpdate({ elevation: value })
}}
precision={3}
className="flex-1"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <span className="text-muted-foreground text-xs shrink-0">m</span>
</div> </div>
@@ -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<HTMLLabelElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className={`${className}`}>
<div className="flex items-center rounded border border-input bg-muted/30 overflow-hidden">
<label
ref={labelRef}
className={`px-2 py-1 text-muted-foreground text-xs select-none ${
isDragging ? 'cursor-ew-resize' : 'hover:cursor-ew-resize hover:text-foreground'
} transition-colors`}
onMouseDown={handleLabelMouseDown}
>
{label}
</label>
{isEditing ? (
<input
autoFocus
className="flex-1 bg-transparent px-2 py-1 text-foreground text-sm outline-none text-right"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
) : (
<div
className="flex-1 px-2 py-1 text-foreground text-sm cursor-text hover:bg-muted/50 transition-colors text-right"
onClick={handleValueClick}
>
{value.toFixed(precision)}
</div>
)}
</div>
</div>
)
}