feat(editor): redesign panel UI with shared control components (#127)
Extract reusable control components (SliderControl, MetricControl, SegmentedControl, ToggleControl, ActionButton, PanelSection) and PanelWrapper to reduce duplication across all property panels. Net reduction of ~500 lines. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2ddf10f3f0
commit
f1d0d3a78c
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
}
|
||||
|
||||
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-xs font-medium text-foreground transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-1.5", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MetricControlProps {
|
||||
label: React.ReactNode
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
precision?: number
|
||||
step?: number
|
||||
className?: string
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export function MetricControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = -Infinity,
|
||||
max = Infinity,
|
||||
precision = 2,
|
||||
step = 1,
|
||||
className,
|
||||
unit = '',
|
||||
}: MetricControlProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||
const startXRef = useRef(0)
|
||||
const startValueRef = useRef(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback(
|
||||
(val: number) => {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
},
|
||||
[min, max],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
}
|
||||
}, [value, precision, isEditing])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || isEditing) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
let direction = 0
|
||||
if (e.key === 'ArrowUp') direction = 1
|
||||
else if (e.key === 'ArrowDown') direction = -1
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, onChange, precision])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
|
||||
setIsDragging(true)
|
||||
startXRef.current = e.clientX
|
||||
startValueRef.current = value
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
let finalValue = value
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startXRef.current
|
||||
|
||||
let dragStep = step
|
||||
if (moveEvent.shiftKey) dragStep = step * 10
|
||||
else if (moveEvent.altKey) dragStep = step * 0.1
|
||||
|
||||
const deltaValue = deltaX * dragStep
|
||||
const newValue = clamp(startValueRef.current + deltaValue)
|
||||
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (newFinalValue !== finalValue) {
|
||||
finalValue = newFinalValue
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsDragging(false)
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
if (finalValue !== startValueRef.current) {
|
||||
onChange(startValueRef.current)
|
||||
useScene.temporal.getState().resume()
|
||||
onChange(finalValue)
|
||||
} else {
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, value, onChange, clamp, precision, step]
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
setInputValue(value.toFixed(precision))
|
||||
}, [value, precision])
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (!Number.isNaN(numValue)) {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
} else {
|
||||
setInputValue(value.toFixed(precision))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
submitValue()
|
||||
}, [submitValue])
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitValue()
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setIsEditing(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value + step)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value - step)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
}
|
||||
},
|
||||
[submitValue, value, precision, step, clamp, onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn("group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground select-none truncate transition-colors",
|
||||
isDragging ? "cursor-ew-resize text-foreground" : "hover:text-foreground hover:cursor-ew-resize"
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground hover:text-primary transition-colors"
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
||||
</span>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface PanelSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultExpanded?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PanelSection({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
}: PanelSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
|
||||
>
|
||||
<motion.button
|
||||
layout="position"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"group/section flex items-center justify-between h-10 px-3 transition-all duration-200 shrink-0",
|
||||
isExpanded
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-sm truncate">{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
isExpanded ? "text-foreground" : "opacity-0 group-hover/section:opacity-100"
|
||||
)}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 p-3 pt-2">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { label: React.ReactNode; value: T }[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={cn("flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]", className)}>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"relative flex h-full flex-1 items-center justify-center rounded-md text-xs font-medium transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50"
|
||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SliderControlProps {
|
||||
label: React.ReactNode
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
precision?: number
|
||||
step?: number
|
||||
className?: string
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export function SliderControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 100,
|
||||
precision = 0,
|
||||
step = 1,
|
||||
className,
|
||||
unit = '',
|
||||
}: SliderControlProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||
|
||||
// Track the original value and bounds when dragging starts
|
||||
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
|
||||
const [dragMin, setDragMin] = useState<number | null>(null)
|
||||
const [dragMax, setDragMax] = useState<number | null>(null)
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback(
|
||||
(val: number) => {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
},
|
||||
[min, max],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
}
|
||||
}, [value, precision, isEditing])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || isEditing) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
let direction = 0
|
||||
if (e.key === 'ArrowUp') direction = 1
|
||||
else if (e.key === 'ArrowDown') direction = -1
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, onChange, precision])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
|
||||
setIsDragging(true)
|
||||
setDragStartValue(value)
|
||||
setDragMin(min)
|
||||
setDragMax(max)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const rect = track.getBoundingClientRect()
|
||||
const updateValueFromEvent = (clientX: number) => {
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const rawValue = min + percent * (max - min)
|
||||
// snap to step
|
||||
const snapped = Math.round(rawValue / step) * step
|
||||
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
|
||||
onChange(finalValue)
|
||||
}
|
||||
|
||||
updateValueFromEvent(e.clientX)
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
updateValueFromEvent(moveEvent.clientX)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
// Only stop dragging if we didn't release on the reset button
|
||||
// Let the reset button's onPointerDown handle its own cleanup
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragging(false)
|
||||
setDragStartValue(null)
|
||||
setDragMin(null)
|
||||
setDragMax(null)
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, min, max, step, precision, clamp, onChange]
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
setInputValue(value.toFixed(precision))
|
||||
}, [value, precision])
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (!Number.isNaN(numValue)) {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
} else {
|
||||
setInputValue(value.toFixed(precision))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
submitValue()
|
||||
}, [submitValue])
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitValue()
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setIsEditing(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value + step)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value - step)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
}
|
||||
},
|
||||
[submitValue, value, precision, step, clamp, onChange],
|
||||
)
|
||||
|
||||
const currentMin = isDragging && dragMin !== null ? dragMin : min
|
||||
const currentMax = isDragging && dragMax !== null ? dragMax : max
|
||||
|
||||
const percent = Math.max(0, Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100))
|
||||
const startPercent = dragStartValue !== null ? Math.max(0, Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100)) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn("group flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors relative", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
|
||||
>
|
||||
{/* Reset button that appears when dragged away from start */}
|
||||
{isDragging && dragStartValue !== null && dragStartValue !== value && (
|
||||
<button
|
||||
className="absolute -top-10 right-0 rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] font-medium text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground z-50 pointer-events-auto cursor-pointer"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(dragStartValue)
|
||||
setDragStartValue(null)
|
||||
setDragMin(null)
|
||||
setDragMax(null)
|
||||
setIsDragging(false)
|
||||
useScene.temporal.getState().resume()
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-[80px] shrink-0 text-muted-foreground select-none truncate">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={cn(
|
||||
"relative flex h-full flex-1 items-center justify-center touch-none mx-2",
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{/* Track dots background */}
|
||||
<div className="absolute inset-x-0 flex items-center justify-between opacity-30 px-1 pointer-events-none">
|
||||
{[...Array(9)].map((_, i) => (
|
||||
<div key={i} className="h-[3px] w-[3px] rounded-full bg-current" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Original Thumb Ghost */}
|
||||
{isDragging && startPercent !== null && (
|
||||
<div
|
||||
className="absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm bg-foreground/20 pointer-events-none"
|
||||
style={{ left: `${startPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Active Thumb */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition pointer-events-none",
|
||||
isDragging ? "bg-foreground scale-y-110" : "bg-foreground/60 group-hover:bg-foreground/80"
|
||||
)}
|
||||
style={{ left: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[50px] shrink-0 justify-end">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground/60 hover:text-foreground transition-colors"
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
||||
</span>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ToggleControlProps {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ToggleControl({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
}: ToggleControlProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]", className)}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200",
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-black/20 text-transparent group-hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Plus, Trash2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Edit, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
|
||||
export function CeilingPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -16,7 +19,6 @@ export function CeilingPanel() {
|
||||
const editingHole = useEditor((s) => s.editingHole)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
|
||||
// Get the first selected node if it's a ceiling
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
|
||||
@@ -35,14 +37,12 @@ export function CeilingPanel() {
|
||||
setEditingHole(null)
|
||||
}, [setSelection, setEditingHole])
|
||||
|
||||
// Clear hole editing state when ceiling is deselected
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
setEditingHole(null)
|
||||
}
|
||||
}, [node, setEditingHole])
|
||||
|
||||
// Clear hole editing state on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEditingHole(null)
|
||||
@@ -52,7 +52,6 @@ export function CeilingPanel() {
|
||||
const handleAddHole = useCallback(() => {
|
||||
if (!node || !selectedId) return
|
||||
|
||||
// Calculate centroid of the ceiling polygon
|
||||
const polygon = node.polygon
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
@@ -63,7 +62,6 @@ export function CeilingPanel() {
|
||||
cx /= polygon.length
|
||||
cz /= polygon.length
|
||||
|
||||
// Create a default small rectangular hole centered at the ceiling's centroid
|
||||
const holeSize = 0.5
|
||||
const newHole: Array<[number, number]> = [
|
||||
[cx - holeSize, cz - holeSize],
|
||||
@@ -73,7 +71,6 @@ export function CeilingPanel() {
|
||||
]
|
||||
const currentHoles = node?.holes || []
|
||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||
// Enter edit mode for the new hole
|
||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||
|
||||
@@ -98,10 +95,8 @@ export function CeilingPanel() {
|
||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||
)
|
||||
|
||||
// Only show if exactly one ceiling is selected
|
||||
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||
if (polygon.length < 3) return 0
|
||||
let area = 0
|
||||
@@ -117,170 +112,107 @@ export function CeilingPanel() {
|
||||
const area = calculateArea(node.polygon)
|
||||
|
||||
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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || "Ceiling"}
|
||||
</h2>
|
||||
<PanelWrapper
|
||||
title={node.name || "Ceiling"}
|
||||
icon="/icons/ceiling.png"
|
||||
onClose={handleClose}
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Height">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0}
|
||||
max={6}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
|
||||
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
|
||||
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
|
||||
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-4">
|
||||
{/* Height */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Height
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(node.height * 1000) / 1000}
|
||||
onChange={(value) => {
|
||||
handleUpdate({ height: value })
|
||||
}}
|
||||
precision={3}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Height from the floor where the ceiling is positioned
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick preset buttons */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Presets
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ height: 2.4 })}
|
||||
>
|
||||
Low (2.4m)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ height: 2.5 })}
|
||||
>
|
||||
Standard (2.5m)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ height: 3.0 })}
|
||||
>
|
||||
High (3m)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area info */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Area
|
||||
</label>
|
||||
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm text-foreground">
|
||||
{area.toFixed(2)} m²
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holes */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Holes
|
||||
</label>
|
||||
{editingHole?.nodeId === selectedId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-green-500 bg-green-500/10 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium text-green-600 hover:bg-green-500/20 transition-colors cursor-pointer"
|
||||
onClick={() => setEditingHole(null)}
|
||||
>
|
||||
<span>Done Editing</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleAddHole}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>Add Hole</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{node.holes && node.holes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{node.holes.map((hole, index) => {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-colors ${
|
||||
isEditing
|
||||
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
|
||||
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} vertices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
onClick={() => handleEditHole(index)}
|
||||
aria-label="Edit hole"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
onClick={() => handleDeleteHole(index)}
|
||||
aria-label="Delete hole"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No holes. Click "Add Hole" to create one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<span>Area</span>
|
||||
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Holes">
|
||||
{node.holes && node.holes.length > 0 ? (
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
{node.holes.map((hole, index) => {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
isEditing
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<ActionButton
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={() => handleEditHole(index)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
|
||||
onClick={() => handleDeleteHole(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No holes
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-1 pt-1 pb-1">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Hole"
|
||||
onClick={handleAddHole}
|
||||
className="w-full"
|
||||
disabled={editingHole?.nodeId === selectedId}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
import { Switch } from '@/components/ui/primitives/switch'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
|
||||
export function DoorPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -136,509 +141,366 @@ export function DoorPanel() {
|
||||
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md max-h-[calc(100dvh-100px)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || "Door"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 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 space-y-4">
|
||||
|
||||
{/* Position */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Position
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<NumberInput
|
||||
label="X along wall"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
<PanelWrapper
|
||||
title={node.name || "Door"}
|
||||
icon="/icons/door.png"
|
||||
onClose={handleClose}
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
min={-10}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<div className="pt-2 pb-1 px-1">
|
||||
<ActionButton
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
>
|
||||
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
||||
Flip Side
|
||||
</button>
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dimensions
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Width"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.5}
|
||||
precision={2}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
|
||||
min={1.0}
|
||||
precision={2}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.5}
|
||||
max={3}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
|
||||
min={1.0}
|
||||
max={4}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{/* Frame */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Frame
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Thickness"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
min={0.01}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Depth"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
min={0.01}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
min={0.01}
|
||||
max={0.2}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
min={0.01}
|
||||
max={0.3}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{/* Content Padding */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Content Padding
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Horizontal"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
min={0}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Vertical"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
min={0}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PanelSection title="Content Padding">
|
||||
<SliderControl
|
||||
label="Horizontal"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
min={0}
|
||||
max={0.2}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Vertical"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
min={0}
|
||||
max={0.2}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{/* Swing */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Swing
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Hinges</span>
|
||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
||||
{(['left', 'right'] as const).map((side) => (
|
||||
<button
|
||||
key={side}
|
||||
type="button"
|
||||
onClick={() => handleUpdate({ hingesSide: side })}
|
||||
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium cursor-pointer transition-all duration-200 ${
|
||||
node.hingesSide === side
|
||||
? 'bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{side.charAt(0).toUpperCase() + side.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Direction</span>
|
||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
||||
{(['inward', 'outward'] as const).map((dir) => (
|
||||
<button
|
||||
key={dir}
|
||||
type="button"
|
||||
onClick={() => handleUpdate({ swingDirection: dir })}
|
||||
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
|
||||
node.swingDirection === dir
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{dir.charAt(0).toUpperCase() + dir.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threshold */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Threshold
|
||||
</label>
|
||||
<Switch
|
||||
checked={node.threshold}
|
||||
onCheckedChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
|
||||
<SegmentedControl
|
||||
value={node.hingesSide}
|
||||
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{node.threshold && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
||||
min={0.005}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Handle */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Handle
|
||||
</label>
|
||||
<Switch
|
||||
checked={node.handle}
|
||||
onCheckedChange={(checked) => handleUpdate({ handle: checked })}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
|
||||
<SegmentedControl
|
||||
value={node.swingDirection}
|
||||
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||
options={[
|
||||
{ label: 'Inward', value: 'inward' },
|
||||
{ label: 'Outward', value: 'outward' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{node.handle && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(node.handleHeight * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||
min={0.5}
|
||||
max={node.height - 0.1}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
label="Enable Threshold"
|
||||
checked={node.threshold}
|
||||
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
/>
|
||||
{node.threshold && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Handle">
|
||||
<ToggleControl
|
||||
label="Enable Handle"
|
||||
checked={node.handle}
|
||||
onChange={(checked) => handleUpdate({ handle: checked })}
|
||||
/>
|
||||
{node.handle && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.handleHeight * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||
min={0.5}
|
||||
max={node.height - 0.1}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
|
||||
<SegmentedControl
|
||||
value={node.handleSide}
|
||||
onChange={(v) => handleUpdate({ handleSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hardware">
|
||||
<ToggleControl
|
||||
label="Door Closer"
|
||||
checked={node.doorCloser}
|
||||
onChange={(checked) => handleUpdate({ doorCloser: checked })}
|
||||
/>
|
||||
<ToggleControl
|
||||
label="Panic Bar"
|
||||
checked={node.panicBar}
|
||||
onChange={(checked) => handleUpdate({ panicBar: checked })}
|
||||
/>
|
||||
{node.panicBar && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Bar Height"
|
||||
value={Math.round(node.panicBarHeight * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
||||
min={0.5}
|
||||
max={node.height - 0.1}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Segments">
|
||||
{node.segments.map((seg, i) => {
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = seg.columnRatios.map(r => r / colSum)
|
||||
return (
|
||||
<div key={i} className="mb-2 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Side</span>
|
||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
||||
{(['left', 'right'] as const).map((side) => (
|
||||
<button
|
||||
key={side}
|
||||
type="button"
|
||||
onClick={() => handleUpdate({ handleSide: side })}
|
||||
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
|
||||
node.handleSide === side
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{side.charAt(0).toUpperCase() + side.slice(1)}
|
||||
</button>
|
||||
|
||||
<SegmentedControl
|
||||
value={seg.type}
|
||||
onChange={(t) => {
|
||||
const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Panel', value: 'panel' },
|
||||
{ label: 'Glass', value: 'glass' },
|
||||
{ label: 'Empty', value: 'empty' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
value={numCols}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
{normCols.map((ratio, ci) => (
|
||||
<SliderControl
|
||||
key={`c-${ci}`}
|
||||
label={`C${ci + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hardware */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Hardware
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">Door Closer</span>
|
||||
<Switch
|
||||
checked={node.doorCloser}
|
||||
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">Panic Bar</span>
|
||||
<Switch
|
||||
checked={node.panicBar}
|
||||
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })}
|
||||
/>
|
||||
</div>
|
||||
{node.panicBar && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Bar height"
|
||||
value={Math.round(node.panicBarHeight * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
||||
min={0.5}
|
||||
max={node.height - 0.1}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segments */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Leaf segments (top → bottom)
|
||||
</label>
|
||||
{node.segments.map((seg, i) => {
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = seg.columnRatios.map(r => r / colSum)
|
||||
return (
|
||||
<div key={i} className="rounded border border-border p-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
|
||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
||||
{(['panel', 'glass', 'empty'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, type: t } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${
|
||||
seg.type === t
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||
</div>
|
||||
{/* Columns */}
|
||||
<div className="space-y-1">
|
||||
<NumberInput
|
||||
label="Columns"
|
||||
value={numCols}
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
|
||||
idx === i ? { ...s, dividerThickness: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
/>
|
||||
{numCols > 1 && (
|
||||
<div className="space-y-1 pl-1">
|
||||
{normCols.map((ratio, ci) => (
|
||||
<div key={ci} className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label={`C${ci + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Divider"
|
||||
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, dividerThickness: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0.005}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seg.type === 'panel' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Inset"
|
||||
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, panelInset: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Depth"
|
||||
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, panelDepth: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
const updated = [
|
||||
...node.segments,
|
||||
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
]
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
>
|
||||
+ Add segment
|
||||
</button>
|
||||
{node.segments.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
handleUpdate({ segments: node.segments.slice(0, -1) })
|
||||
}}
|
||||
>
|
||||
− Remove last
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleMove}
|
||||
>
|
||||
<Move className="h-3.5 w-3.5" />
|
||||
<span>Move</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleDuplicate}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>Duplicate</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
{seg.type === 'panel' && (
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<SliderControl
|
||||
label="Inset"
|
||||
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, panelInset: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, panelDepth: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex gap-1.5 px-1 pt-1">
|
||||
<ActionButton
|
||||
label="+ Add Segment"
|
||||
onClick={() => {
|
||||
const updated = [
|
||||
...node.segments,
|
||||
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
]
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
/>
|
||||
{node.segments.length > 1 && (
|
||||
<ActionButton
|
||||
label="- Remove"
|
||||
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
|
||||
className="text-white/60 hover:text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||
<ActionButton
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
className="hover:bg-red-500/20"
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Link, Link2Off, Move, Trash2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
|
||||
export function ItemPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -17,7 +22,6 @@ export function ItemPanel() {
|
||||
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)
|
||||
@@ -30,7 +34,6 @@ export function ItemPanel() {
|
||||
if (!selectedId || !node) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
|
||||
// Mark parent wall as dirty if item is attached to wall
|
||||
if (node.asset.attachTo === 'wall' && node.parentId) {
|
||||
requestAnimationFrame(() => {
|
||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
|
||||
@@ -48,7 +51,6 @@ export function ItemPanel() {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
// Deselect so the panel closes
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
@@ -56,8 +58,6 @@ export function ItemPanel() {
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
// Create a proto node (not added to scene) as a carrier for asset/position info.
|
||||
// MoveItemContent detects metadata.isNew and uses draftNode.create() so ghost rendering works correctly.
|
||||
const proto = ItemNode.parse({
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: [...node.rotation] as [number, number, number],
|
||||
@@ -78,221 +78,171 @@ export function ItemPanel() {
|
||||
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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<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"
|
||||
<PanelWrapper
|
||||
title={node.name || node.asset.name}
|
||||
icon={node.asset.thumbnail || '/icons/furniture.png'}
|
||||
onClose={handleClose}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
|
||||
min={node.position[0] - 2}
|
||||
max={node.position[0] + 2}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
|
||||
min={node.position[1] - 2}
|
||||
max={node.position[1] + 2}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
|
||||
min={node.position[2] - 2}
|
||||
max={node.position[2] + 2}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||
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]] })
|
||||
}}
|
||||
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
|
||||
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||
const radians = ((currentDegrees - 45) * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||
const radians = ((currentDegrees + 45) * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
/>
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || node.asset.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{/* 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 font-barlow 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 font-barlow 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={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
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={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
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>
|
||||
|
||||
{/* Scale */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Scale
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
onClick={() => setUniformScale((v) => !v)}
|
||||
title={uniformScale ? 'Unlock axes' : 'Lock axes'}
|
||||
>
|
||||
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
{uniformScale ? (
|
||||
<NumberInput
|
||||
label="XYZ"
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
const v = Math.max(0.01, value)
|
||||
handleUpdate({ scale: [v, v, v] })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<NumberInput
|
||||
label="X"
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Y"
|
||||
value={Math.round(node.scale[1] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Z"
|
||||
value={Math.round(node.scale[2] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<PanelSection title="Scale">
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md transition-colors text-muted-foreground hover:text-foreground",
|
||||
uniformScale ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimensions (effective, read-only) */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dimensions
|
||||
</label>
|
||||
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm font-mono text-foreground">
|
||||
{(() => {
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
return `${Math.round(w * 100) / 100}m × ${Math.round(h * 100) / 100}m × ${Math.round(d * 100) / 100}m`
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
onClick={() => setUniformScale((v) => !v)}
|
||||
>
|
||||
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleMove}
|
||||
>
|
||||
<Move className="h-3.5 w-3.5" />
|
||||
<span>Move</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleDuplicate}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>Duplicate</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
{uniformScale ? (
|
||||
<SliderControl
|
||||
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
const v = Math.max(0.01, value)
|
||||
handleUpdate({ scale: [v, v, v] })
|
||||
}}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale[1] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale[2] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<span>Dimensions</span>
|
||||
{(() => {
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
return (
|
||||
<span className="font-mono text-white">
|
||||
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||
<ActionButton
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
className="hover:bg-red-500/20"
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X, RotateCcw, Moon } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface PanelWrapperProps {
|
||||
title: string
|
||||
icon?: string
|
||||
onClose?: () => void
|
||||
onReset?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
width?: number | string
|
||||
}
|
||||
|
||||
export function PanelWrapper({
|
||||
title,
|
||||
icon,
|
||||
onClose,
|
||||
onReset,
|
||||
children,
|
||||
className,
|
||||
width = 320, // default width
|
||||
}: PanelWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto fixed right-4 top-20 z-50 flex flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground max-h-[calc(100dvh-100px)]",
|
||||
className
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<Image
|
||||
src={icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
<h2 className="font-semibold text-foreground text-sm truncate tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 no-scrollbar flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
|
||||
import { Box, Image, X } from 'lucide-react'
|
||||
import { Box, Image as ImageIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
|
||||
type ReferenceNode = ScanNode | GuideNode
|
||||
|
||||
@@ -35,132 +40,111 @@ export function ReferencePanel() {
|
||||
const isScan = node.type === 'scan'
|
||||
|
||||
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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isScan ? (
|
||||
<Box className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
||||
</h2>
|
||||
<PanelWrapper
|
||||
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
||||
icon={isScan ? undefined : undefined}
|
||||
onClose={handleClose}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||
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]],
|
||||
})
|
||||
}}
|
||||
min={-180}
|
||||
max={180}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]] })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]] })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{/* 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 font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Position
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([0, 1, 2] as const).map((i) => (
|
||||
<NumberInput
|
||||
key={i}
|
||||
label={['X', 'Y', 'Z'][i]!}
|
||||
value={Math.round(node.position[i] * 100) / 100}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[i] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PanelSection title="Scale & Opacity">
|
||||
<SliderControl
|
||||
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale * 100) / 100}
|
||||
onChange={(value) => {
|
||||
if (value > 0) {
|
||||
handleUpdate({ scale: value })
|
||||
}
|
||||
}}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
|
||||
{/* Rotation Y */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Rotation
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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="min-w-0 flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
||||
<button
|
||||
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
>
|
||||
−45
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
>
|
||||
+45
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scale */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Scale
|
||||
</label>
|
||||
<NumberInput
|
||||
label="Scale"
|
||||
value={Math.round(node.scale * 100) / 100}
|
||||
onChange={(value) => {
|
||||
if (value > 0) {
|
||||
handleUpdate({ scale: value })
|
||||
}
|
||||
}}
|
||||
min={0.01}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Opacity */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Opacity
|
||||
</label>
|
||||
<span className="text-muted-foreground font-mono text-xs">{node.opacity}%</span>
|
||||
</div>
|
||||
<input
|
||||
className="w-full cursor-pointer"
|
||||
max="100"
|
||||
min="0"
|
||||
onChange={(e) => handleUpdate({ opacity: Number.parseInt(e.target.value, 10) })}
|
||||
step="1"
|
||||
type="range"
|
||||
value={node.opacity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Opacity"
|
||||
value={node.opacity}
|
||||
onChange={(v) => handleUpdate({ opacity: v })}
|
||||
min={0}
|
||||
max={100}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="%"
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
|
||||
export function RoofPanel() {
|
||||
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)
|
||||
|
||||
// Get the first selected node if it's a roof
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
|
||||
@@ -30,204 +33,137 @@ export function RoofPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
// Only show if exactly one roof is selected
|
||||
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
||||
|
||||
// Calculate total width for display
|
||||
const totalWidth = node.leftWidth + node.rightWidth
|
||||
|
||||
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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || "Roof"}
|
||||
</h2>
|
||||
<PanelWrapper
|
||||
title={node.name || "Roof"}
|
||||
icon="/icons/roof.png"
|
||||
onClose={handleClose}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Length"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ length: v })}
|
||||
min={0.5}
|
||||
max={20}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0.1}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Slope Widths">
|
||||
<div className="flex items-center justify-between px-2 pb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
|
||||
<span>Widths</span>
|
||||
<span>Total: {totalWidth.toFixed(1)}m</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Left"
|
||||
value={Math.round(node.leftWidth * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ leftWidth: v })}
|
||||
min={0.1}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Right"
|
||||
value={Math.round(node.rightWidth * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ rightWidth: v })}
|
||||
min={0.1}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-4">
|
||||
{/* Length */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Length
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
min="0.5"
|
||||
onChange={(e) => {
|
||||
const value = Number.parseFloat(e.target.value)
|
||||
if (!Number.isNaN(value) && value > 0) {
|
||||
handleUpdate({ length: value })
|
||||
}
|
||||
}}
|
||||
step="0.5"
|
||||
type="number"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Height
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
min="0.1"
|
||||
onChange={(e) => {
|
||||
const value = Number.parseFloat(e.target.value)
|
||||
if (!Number.isNaN(value) && value > 0) {
|
||||
handleUpdate({ height: value })
|
||||
}
|
||||
}}
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slope Widths */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Slope Widths
|
||||
</label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Total: {totalWidth.toFixed(1)}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs">Left</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
min="0.1"
|
||||
onChange={(e) => {
|
||||
const value = Number.parseFloat(e.target.value)
|
||||
if (!Number.isNaN(value) && value > 0) {
|
||||
handleUpdate({ leftWidth: value })
|
||||
}
|
||||
}}
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={Math.round(node.leftWidth * 100) / 100}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs">Right</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
min="0.1"
|
||||
onChange={(e) => {
|
||||
const value = Number.parseFloat(e.target.value)
|
||||
if (!Number.isNaN(value) && value > 0) {
|
||||
handleUpdate({ rightWidth: value })
|
||||
}
|
||||
}}
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={Math.round(node.rightWidth * 100) / 100}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rotation */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Rotation
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
onChange={(e) => {
|
||||
const degrees = Number.parseFloat(e.target.value)
|
||||
if (!Number.isNaN(degrees)) {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({ rotation: radians })
|
||||
}
|
||||
}}
|
||||
step="1"
|
||||
type="number"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
const newRotation = node.rotation - Math.PI / 2
|
||||
handleUpdate({ rotation: newRotation })
|
||||
}}
|
||||
>
|
||||
−90
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
const newRotation = node.rotation + Math.PI / 2
|
||||
handleUpdate({ rotation: newRotation })
|
||||
}}
|
||||
>
|
||||
+90
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Position
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([0, 1, 2] as const).map((i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
onChange={(e) => {
|
||||
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.5"
|
||||
type="number"
|
||||
value={Math.round(node.position[i] * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
onChange={(degrees) => {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({ rotation: radians })
|
||||
}}
|
||||
min={-180}
|
||||
max={180}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation - Math.PI / 2 })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation + Math.PI / 2 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Plus, Trash2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Edit, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
|
||||
export function SlabPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -16,7 +19,6 @@ export function SlabPanel() {
|
||||
const editingHole = useEditor((s) => s.editingHole)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
|
||||
// Get the first selected node if it's a slab
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
|
||||
@@ -35,14 +37,12 @@ export function SlabPanel() {
|
||||
setEditingHole(null)
|
||||
}, [setSelection, setEditingHole])
|
||||
|
||||
// Clear hole editing state when slab is deselected
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
setEditingHole(null)
|
||||
}
|
||||
}, [node, setEditingHole])
|
||||
|
||||
// Clear hole editing state on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEditingHole(null)
|
||||
@@ -52,7 +52,6 @@ export function SlabPanel() {
|
||||
const handleAddHole = useCallback(() => {
|
||||
if (!node || !selectedId) return
|
||||
|
||||
// Calculate centroid of the slab polygon
|
||||
const polygon = node.polygon
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
@@ -63,7 +62,6 @@ export function SlabPanel() {
|
||||
cx /= polygon.length
|
||||
cz /= polygon.length
|
||||
|
||||
// Create a default small rectangular hole centered at the slab's centroid
|
||||
const holeSize = 0.5
|
||||
const newHole: Array<[number, number]> = [
|
||||
[cx - holeSize, cz - holeSize],
|
||||
@@ -73,7 +71,6 @@ export function SlabPanel() {
|
||||
]
|
||||
const currentHoles = node?.holes || []
|
||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||
// Enter edit mode for the new hole
|
||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||
|
||||
@@ -98,10 +95,8 @@ export function SlabPanel() {
|
||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||
)
|
||||
|
||||
// Only show if exactly one slab is selected
|
||||
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||
if (polygon.length < 3) return 0
|
||||
let area = 0
|
||||
@@ -117,177 +112,108 @@ export function SlabPanel() {
|
||||
const area = calculateArea(node.polygon)
|
||||
|
||||
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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || "Slab"}
|
||||
</h2>
|
||||
<PanelWrapper
|
||||
title={node.name || "Slab"}
|
||||
icon="/icons/floor.png"
|
||||
onClose={handleClose}
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Elevation">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.elevation * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ elevation: v })}
|
||||
min={-1}
|
||||
max={1}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
|
||||
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
|
||||
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
|
||||
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
|
||||
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-4">
|
||||
{/* Elevation */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Elevation
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
label="Elevation"
|
||||
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>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Height offset from the level base (positive = raised, negative = sunken)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick preset buttons */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Presets
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ elevation: -0.15 })}
|
||||
>
|
||||
Sunken (-15cm)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ elevation: 0 })}
|
||||
>
|
||||
Ground (0m)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ elevation: 0.05 })}
|
||||
>
|
||||
Raised (5cm)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleUpdate({ elevation: 0.15 })}
|
||||
>
|
||||
Step (15cm)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area info */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Area
|
||||
</label>
|
||||
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm text-foreground">
|
||||
{area.toFixed(2)} m²
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holes */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Holes
|
||||
</label>
|
||||
{editingHole?.nodeId === selectedId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-green-500 bg-green-500/10 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium text-green-600 hover:bg-green-500/20 transition-colors cursor-pointer"
|
||||
onClick={() => setEditingHole(null)}
|
||||
>
|
||||
<span>Done Editing</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleAddHole}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>Add Hole</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{node.holes && node.holes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{node.holes.map((hole, index) => {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-colors ${
|
||||
isEditing
|
||||
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
|
||||
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} vertices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
onClick={() => handleEditHole(index)}
|
||||
aria-label="Edit hole"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
onClick={() => handleDeleteHole(index)}
|
||||
aria-label="Delete hole"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No holes. Click "Add Hole" to create one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<span>Area</span>
|
||||
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Holes">
|
||||
{node.holes && node.holes.length > 0 ? (
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
{node.holes.map((hole, index) => {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
isEditing
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<ActionButton
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={() => handleEditHole(index)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
|
||||
onClick={() => handleDeleteHole(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No holes
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-1 pt-1 pb-1">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Hole"
|
||||
onClick={handleAddHole}
|
||||
className="w-full"
|
||||
disabled={editingHole?.nodeId === selectedId}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
|
||||
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'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
|
||||
export function WallPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -41,68 +42,41 @@ export function WallPanel() {
|
||||
const thickness = node.thickness ?? 0.1
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-64 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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || "Wall"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<PanelWrapper
|
||||
title={node.name || "Wall"}
|
||||
icon="/icons/wall.png"
|
||||
onClose={handleClose}
|
||||
width={280}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||
min={0.1}
|
||||
max={6}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||
min={0.05}
|
||||
max={1}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dimensions
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||
min={0.1}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Thickness"
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||
min={0.05}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<span>Length</span>
|
||||
<span className="font-mono text-white">{length.toFixed(2)} m</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Info
|
||||
</label>
|
||||
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm font-mono text-foreground">
|
||||
Length: {length.toFixed(2)} m
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
import { Switch } from '@/components/ui/primitives/switch'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
|
||||
export function WindowPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -80,7 +84,7 @@ export function WindowPanel() {
|
||||
sill: node.sill,
|
||||
sillDepth: node.sillDepth,
|
||||
sillThickness: node.sillThickness,
|
||||
metadata: { isNew: true },
|
||||
metadata: { isNew: true },
|
||||
})
|
||||
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
||||
setMovingNode(duplicate)
|
||||
@@ -92,7 +96,6 @@ export function WindowPanel() {
|
||||
const numCols = node.columnRatios.length
|
||||
const numRows = node.rowRatios.length
|
||||
|
||||
// Normalized ratios (always sum to 1 for display)
|
||||
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = node.columnRatios.map(r => r / colSum)
|
||||
@@ -125,292 +128,222 @@ export function WindowPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 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 border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||
{node.name || "Window"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 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 space-y-4">
|
||||
|
||||
{/* Position */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Position
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumberInput
|
||||
label="X"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Y"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
<PanelWrapper
|
||||
title={node.name || "Window"}
|
||||
icon="/icons/window.png"
|
||||
onClose={handleClose}
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
min={-10}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
min={-10}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<div className="pt-2 pb-1 px-1">
|
||||
<ActionButton
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
>
|
||||
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
||||
Flip Side
|
||||
</button>
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dimensions
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Width"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.2}
|
||||
precision={2}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0.2}
|
||||
precision={2}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.2}
|
||||
max={5}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0.2}
|
||||
max={5}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{/* Frame */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Frame
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Thickness"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
min={0.01}
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
min={0.01}
|
||||
max={0.2}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
min={0.01}
|
||||
max={0.3}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
value={numCols}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
value={numRows}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
unit="m"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Depth"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
min={0.01}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
unit="m"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Grid
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumberInput
|
||||
label="Columns"
|
||||
value={numCols}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
label="Enable Sill"
|
||||
checked={node.sill}
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
min={0.01}
|
||||
max={0.5}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rows"
|
||||
value={numRows}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
min={0.005}
|
||||
max={0.2}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{/* Column ratios */}
|
||||
{numCols > 1 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground text-xs">Column widths</span>
|
||||
{normCols.map((ratio, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label={`C${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Col divider"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
min={0.005}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row ratios */}
|
||||
{numRows > 1 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground text-xs">Row heights</span>
|
||||
{normRows.map((ratio, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label={`R${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Row divider"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
min={0.005}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sill */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Sill
|
||||
</label>
|
||||
<Switch
|
||||
checked={node.sill}
|
||||
onCheckedChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
</div>
|
||||
{node.sill && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Depth"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
min={0.01}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Thickness"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
min={0.005}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleMove}
|
||||
>
|
||||
<Move className="h-3.5 w-3.5" />
|
||||
<span>Move</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={handleDuplicate}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>Duplicate</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||
<ActionButton
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
className="hover:bg-red-500/20"
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function AppSidebar() {
|
||||
|
||||
{mounted && (
|
||||
<button
|
||||
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer"
|
||||
className="shrink-0 flex items-center bg-black/20 rounded-full p-1 border border-border/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
@@ -139,7 +139,7 @@ export function AppSidebar() {
|
||||
<div className="relative flex">
|
||||
{/* Sliding Background */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
|
||||
className="absolute inset-0 bg-[#3A3A3C] shadow-sm rounded-full"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: theme === "light" ? "100%" : "0%",
|
||||
|
||||
@@ -779,13 +779,13 @@ function LayerToggle() {
|
||||
phase === "structure" && structureLayer === "zones" ? "zones" : "none";
|
||||
|
||||
return (
|
||||
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50 relative">
|
||||
<div className="flex items-center p-1 bg-[#2C2C2E] gap-1 border-b border-border/50 relative">
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
activeTab === "structure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => {
|
||||
setPhase("structure");
|
||||
@@ -795,7 +795,7 @@ function LayerToggle() {
|
||||
{activeTab === "structure" && (
|
||||
<motion.div
|
||||
layoutId="layerToggleActiveBg"
|
||||
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
|
||||
className="absolute inset-0 bg-[#3e3e3e] shadow-sm ring-1 ring-border/50 rounded-md"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
@@ -819,7 +819,7 @@ function LayerToggle() {
|
||||
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
activeTab === "furnish"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => {
|
||||
setPhase("furnish");
|
||||
@@ -828,7 +828,7 @@ function LayerToggle() {
|
||||
{activeTab === "furnish" && (
|
||||
<motion.div
|
||||
layoutId="layerToggleActiveBg"
|
||||
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
|
||||
className="absolute inset-0 bg-[#3e3e3e] shadow-sm ring-1 ring-border/50 rounded-md"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
@@ -852,7 +852,7 @@ function LayerToggle() {
|
||||
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
activeTab === "zones"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => {
|
||||
setPhase("structure");
|
||||
@@ -862,7 +862,7 @@ function LayerToggle() {
|
||||
{activeTab === "zones" && (
|
||||
<motion.div
|
||||
layoutId="layerToggleActiveBg"
|
||||
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
|
||||
className="absolute inset-0 bg-[#3e3e3e] shadow-sm ring-1 ring-border/50 rounded-md"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user