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 { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Plus, Trash2, X } from 'lucide-react'
|
import { Edit, Plus, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
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() {
|
export function CeilingPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -16,7 +19,6 @@ export function CeilingPanel() {
|
|||||||
const editingHole = useEditor((s) => s.editingHole)
|
const editingHole = useEditor((s) => s.editingHole)
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||||
|
|
||||||
// Get the first selected node if it's a ceiling
|
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
const node = selectedId
|
const node = selectedId
|
||||||
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
|
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
|
||||||
@@ -35,14 +37,12 @@ export function CeilingPanel() {
|
|||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
}, [setSelection, setEditingHole])
|
}, [setSelection, setEditingHole])
|
||||||
|
|
||||||
// Clear hole editing state when ceiling is deselected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
}
|
}
|
||||||
}, [node, setEditingHole])
|
}, [node, setEditingHole])
|
||||||
|
|
||||||
// Clear hole editing state on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -52,7 +52,6 @@ export function CeilingPanel() {
|
|||||||
const handleAddHole = useCallback(() => {
|
const handleAddHole = useCallback(() => {
|
||||||
if (!node || !selectedId) return
|
if (!node || !selectedId) return
|
||||||
|
|
||||||
// Calculate centroid of the ceiling polygon
|
|
||||||
const polygon = node.polygon
|
const polygon = node.polygon
|
||||||
let cx = 0
|
let cx = 0
|
||||||
let cz = 0
|
let cz = 0
|
||||||
@@ -63,7 +62,6 @@ export function CeilingPanel() {
|
|||||||
cx /= polygon.length
|
cx /= polygon.length
|
||||||
cz /= polygon.length
|
cz /= polygon.length
|
||||||
|
|
||||||
// Create a default small rectangular hole centered at the ceiling's centroid
|
|
||||||
const holeSize = 0.5
|
const holeSize = 0.5
|
||||||
const newHole: Array<[number, number]> = [
|
const newHole: Array<[number, number]> = [
|
||||||
[cx - holeSize, cz - holeSize],
|
[cx - holeSize, cz - holeSize],
|
||||||
@@ -73,7 +71,6 @@ export function CeilingPanel() {
|
|||||||
]
|
]
|
||||||
const currentHoles = node?.holes || []
|
const currentHoles = node?.holes || []
|
||||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||||
// Enter edit mode for the new hole
|
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||||
|
|
||||||
@@ -98,10 +95,8 @@ export function CeilingPanel() {
|
|||||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only show if exactly one ceiling is selected
|
|
||||||
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
// Calculate approximate area from polygon
|
|
||||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||||
if (polygon.length < 3) return 0
|
if (polygon.length < 3) return 0
|
||||||
let area = 0
|
let area = 0
|
||||||
@@ -117,152 +112,81 @@ export function CeilingPanel() {
|
|||||||
const area = calculateArea(node.polygon)
|
const area = calculateArea(node.polygon)
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || "Ceiling"}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon="/icons/ceiling.png"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
width={320}
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
|
||||||
{node.name || "Ceiling"}
|
|
||||||
</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" />
|
<PanelSection title="Height">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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"
|
label="Height"
|
||||||
value={Math.round(node.height * 1000) / 1000}
|
value={Math.round(node.height * 1000) / 1000}
|
||||||
onChange={(value) => {
|
onChange={(v) => handleUpdate({ height: v })}
|
||||||
handleUpdate({ height: value })
|
min={0}
|
||||||
}}
|
max={6}
|
||||||
precision={3}
|
precision={3}
|
||||||
className="flex-1"
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<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="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
|
||||||
<div className="space-y-2">
|
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
|
||||||
Presets
|
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
|
||||||
</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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Area info */}
|
<PanelSection title="Info">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<span>Area</span>
|
||||||
Area
|
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
||||||
</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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Holes */}
|
<PanelSection title="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 ? (
|
{node.holes && node.holes.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-1 pb-2">
|
||||||
{node.holes.map((hole, index) => {
|
{node.holes.map((hole, index) => {
|
||||||
const holeArea = calculateArea(hole)
|
const holeArea = calculateArea(hole)
|
||||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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 ${
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||||
isEditing
|
isEditing
|
||||||
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
|
? 'border-primary/50 bg-primary/10'
|
||||||
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
|
: 'border-transparent hover:bg-accent/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
|
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
|
||||||
Hole {index + 1} {isEditing && '(Editing)'}
|
Hole {index + 1} {isEditing && '(Editing)'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{holeArea.toFixed(2)} m² · {hole.length} vertices
|
{holeArea.toFixed(2)} m² · {hole.length} pts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{!isEditing && (
|
{isEditing ? (
|
||||||
|
<ActionButton
|
||||||
|
label="Done"
|
||||||
|
onClick={() => setEditingHole(null)}
|
||||||
|
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
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)}
|
onClick={() => handleEditHole(index)}
|
||||||
aria-label="Edit hole"
|
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
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)}
|
onClick={() => handleDeleteHole(index)}
|
||||||
aria-label="Delete hole"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -274,13 +198,21 @@ export function CeilingPanel() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||||
No holes. Click "Add Hole" to create one.
|
No holes
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
</PanelWrapper>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
|
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import useEditor from '@/store/use-editor'
|
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() {
|
export function DoorPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -136,236 +141,160 @@ export function DoorPanel() {
|
|||||||
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
|
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
|
||||||
|
|
||||||
return (
|
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)]">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || "Door"}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon="/icons/door.png"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
width={320}
|
||||||
<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" />
|
<PanelSection title="Position">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
|
||||||
|
|
||||||
{/* 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}
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||||
|
min={-10}
|
||||||
|
max={10}
|
||||||
precision={2}
|
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}
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</PanelSection>
|
||||||
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"
|
|
||||||
onClick={handleFlip}
|
|
||||||
>
|
|
||||||
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
|
||||||
Flip Side
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dimensions */}
|
<PanelSection title="Dimensions">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<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"
|
label="Width"
|
||||||
value={Math.round(node.width * 100) / 100}
|
value={Math.round(node.width * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ width: v })}
|
onChange={(v) => handleUpdate({ width: v })}
|
||||||
min={0.5}
|
min={0.5}
|
||||||
|
max={3}
|
||||||
precision={2}
|
precision={2}
|
||||||
className="flex-1"
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Height"
|
label="Height"
|
||||||
value={Math.round(node.height * 100) / 100}
|
value={Math.round(node.height * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
|
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
|
||||||
min={1.0}
|
min={1.0}
|
||||||
|
max={4}
|
||||||
precision={2}
|
precision={2}
|
||||||
className="flex-1"
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Frame */}
|
<PanelSection title="Frame">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<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"
|
label="Thickness"
|
||||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
max={0.2}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Depth"
|
label="Depth"
|
||||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
max={0.3}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Padding */}
|
<PanelSection title="Content Padding">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<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"
|
label="Horizontal"
|
||||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={0.2}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Vertical"
|
label="Vertical"
|
||||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={0.2}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Swing */}
|
<PanelSection title="Swing">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||||
<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">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">Hinges</span>
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
|
||||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
<SegmentedControl
|
||||||
{(['left', 'right'] as const).map((side) => (
|
value={node.hingesSide}
|
||||||
<button
|
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||||
key={side}
|
options={[
|
||||||
type="button"
|
{ label: 'Left', value: 'left' },
|
||||||
onClick={() => handleUpdate({ hingesSide: side })}
|
{ label: 'Right', value: 'right' },
|
||||||
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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">Direction</span>
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
|
||||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
<SegmentedControl
|
||||||
{(['inward', 'outward'] as const).map((dir) => (
|
value={node.swingDirection}
|
||||||
<button
|
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||||
key={dir}
|
options={[
|
||||||
type="button"
|
{ label: 'Inward', value: 'inward' },
|
||||||
onClick={() => handleUpdate({ swingDirection: dir })}
|
{ label: 'Outward', value: 'outward' },
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Threshold */}
|
<PanelSection title="Threshold">
|
||||||
<div className="space-y-2">
|
<ToggleControl
|
||||||
<div className="flex items-center justify-between">
|
label="Enable Threshold"
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
|
||||||
Threshold
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
checked={node.threshold}
|
checked={node.threshold}
|
||||||
onCheckedChange={(checked) => handleUpdate({ threshold: checked })}
|
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{node.threshold && (
|
{node.threshold && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Height"
|
label="Height"
|
||||||
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
||||||
min={0.005}
|
min={0.005}
|
||||||
|
max={0.1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelSection>
|
||||||
|
|
||||||
{/* Handle */}
|
<PanelSection title="Handle">
|
||||||
<div className="space-y-2">
|
<ToggleControl
|
||||||
<div className="flex items-center justify-between">
|
label="Enable Handle"
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
|
||||||
Handle
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
checked={node.handle}
|
checked={node.handle}
|
||||||
onCheckedChange={(checked) => handleUpdate({ handle: checked })}
|
onChange={(checked) => handleUpdate({ handle: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{node.handle && (
|
{node.handle && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
|
||||||
label="Height"
|
label="Height"
|
||||||
value={Math.round(node.handleHeight * 100) / 100}
|
value={Math.round(node.handleHeight * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ handleHeight: v })}
|
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||||
@@ -373,108 +302,75 @@ export function DoorPanel() {
|
|||||||
max={node.height - 0.1}
|
max={node.height - 0.1}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">Side</span>
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
|
||||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
<SegmentedControl
|
||||||
{(['left', 'right'] as const).map((side) => (
|
value={node.handleSide}
|
||||||
<button
|
onChange={(v) => handleUpdate({ handleSide: v })}
|
||||||
key={side}
|
options={[
|
||||||
type="button"
|
{ label: 'Left', value: 'left' },
|
||||||
onClick={() => handleUpdate({ handleSide: side })}
|
{ label: 'Right', value: 'right' },
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelSection>
|
||||||
|
|
||||||
{/* Hardware */}
|
<PanelSection title="Hardware">
|
||||||
<div className="space-y-2">
|
<ToggleControl
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
label="Door Closer"
|
||||||
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}
|
checked={node.doorCloser}
|
||||||
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })}
|
onChange={(checked) => handleUpdate({ doorCloser: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
<ToggleControl
|
||||||
<div className="flex items-center justify-between">
|
label="Panic Bar"
|
||||||
<span className="text-sm text-foreground">Panic Bar</span>
|
|
||||||
<Switch
|
|
||||||
checked={node.panicBar}
|
checked={node.panicBar}
|
||||||
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })}
|
onChange={(checked) => handleUpdate({ panicBar: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{node.panicBar && (
|
{node.panicBar && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Bar height"
|
label="Bar Height"
|
||||||
value={Math.round(node.panicBarHeight * 100) / 100}
|
value={Math.round(node.panicBarHeight * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
||||||
min={0.5}
|
min={0.5}
|
||||||
max={node.height - 0.1}
|
max={node.height - 0.1}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Segments */}
|
<PanelSection title="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) => {
|
{node.segments.map((seg, i) => {
|
||||||
const numCols = seg.columnRatios.length
|
const numCols = seg.columnRatios.length
|
||||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
const normCols = seg.columnRatios.map(r => r / colSum)
|
const normCols = seg.columnRatios.map(r => r / colSum)
|
||||||
return (
|
return (
|
||||||
<div key={i} className="rounded border border-border p-2 space-y-2">
|
<div key={i} className="mb-2 flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between pb-1">
|
||||||
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
|
<span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
|
||||||
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
|
</div>
|
||||||
{(['panel', 'glass', 'empty'] as const).map((t) => (
|
|
||||||
<button
|
<SegmentedControl
|
||||||
key={t}
|
value={seg.type}
|
||||||
type="button"
|
onChange={(t) => {
|
||||||
onClick={() => {
|
const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s)
|
||||||
const updated = node.segments.map((s, idx) =>
|
|
||||||
idx === i ? { ...s, type: t } : s,
|
|
||||||
)
|
|
||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${
|
options={[
|
||||||
seg.type === t
|
{ label: 'Panel', value: 'panel' },
|
||||||
? 'border-primary bg-primary text-primary-foreground'
|
{ label: 'Glass', value: 'glass' },
|
||||||
: 'border-border hover:bg-accent'
|
{ label: 'Empty', value: 'empty' },
|
||||||
}`}
|
]}
|
||||||
>
|
/>
|
||||||
{t}
|
|
||||||
</button>
|
<SliderControl
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Height"
|
label="Height"
|
||||||
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||||
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||||
@@ -482,13 +378,10 @@ export function DoorPanel() {
|
|||||||
max={95}
|
max={95}
|
||||||
precision={1}
|
precision={1}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-1"
|
unit="%"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
|
||||||
</div>
|
<SliderControl
|
||||||
{/* Columns */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumberInput
|
|
||||||
label="Columns"
|
label="Columns"
|
||||||
value={numCols}
|
value={numCols}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -503,11 +396,12 @@ export function DoorPanel() {
|
|||||||
precision={0}
|
precision={0}
|
||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{numCols > 1 && (
|
{numCols > 1 && (
|
||||||
<div className="space-y-1 pl-1">
|
<div className="mt-1 border-t border-border/50 pt-1">
|
||||||
{normCols.map((ratio, ci) => (
|
{normCols.map((ratio, ci) => (
|
||||||
<div key={ci} className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
key={`c-${ci}`}
|
||||||
label={`C${ci + 1}`}
|
label={`C${ci + 1}`}
|
||||||
value={Math.round(ratio * 100 * 10) / 10}
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||||
@@ -515,13 +409,10 @@ export function DoorPanel() {
|
|||||||
max={95}
|
max={95}
|
||||||
precision={1}
|
precision={1}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-1"
|
unit="%"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
|
||||||
label="Divider"
|
label="Divider"
|
||||||
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -531,19 +422,17 @@ export function DoorPanel() {
|
|||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
min={0.005}
|
min={0.005}
|
||||||
|
max={0.1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{seg.type === 'panel' && (
|
{seg.type === 'panel' && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="mt-1 border-t border-border/50 pt-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
|
||||||
label="Inset"
|
label="Inset"
|
||||||
value={Math.round(seg.panelInset * 1000) / 1000}
|
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -553,14 +442,12 @@ export function DoorPanel() {
|
|||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={0.1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Depth"
|
label="Depth"
|
||||||
value={Math.round(seg.panelDepth * 1000) / 1000}
|
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -570,21 +457,20 @@ export function DoorPanel() {
|
|||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={0.1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<div className="flex gap-1.5 px-1 pt-1">
|
||||||
type="button"
|
<ActionButton
|
||||||
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"
|
label="+ Add Segment"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const updated = [
|
const updated = [
|
||||||
...node.segments,
|
...node.segments,
|
||||||
@@ -592,53 +478,29 @@ export function DoorPanel() {
|
|||||||
]
|
]
|
||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
+ Add segment
|
|
||||||
</button>
|
|
||||||
{node.segments.length > 1 && (
|
{node.segments.length > 1 && (
|
||||||
<button
|
<ActionButton
|
||||||
type="button"
|
label="- Remove"
|
||||||
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) })}
|
||||||
onClick={() => {
|
className="text-white/60 hover:text-white"
|
||||||
handleUpdate({ segments: node.segments.slice(0, -1) })
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
− Remove last
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<PanelSection title="Actions">
|
||||||
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
<ActionGroup>
|
||||||
<div className="flex gap-2">
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
<button
|
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||||
type="button"
|
<ActionButton
|
||||||
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"
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
onClick={handleMove}
|
label="Delete"
|
||||||
>
|
|
||||||
<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}
|
onClick={handleDelete}
|
||||||
>
|
className="hover:bg-red-500/20"
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
/>
|
||||||
<span>Delete</span>
|
</ActionGroup>
|
||||||
</button>
|
</PanelSection>
|
||||||
</div>
|
</PanelWrapper>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
|
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Copy, Link, Link2Off, Move, Trash2, X } from 'lucide-react'
|
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
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() {
|
export function ItemPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -17,7 +22,6 @@ export function ItemPanel() {
|
|||||||
const deleteNode = useScene((s) => s.deleteNode)
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
// Get the first selected node if it's an item
|
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
const node = selectedId
|
const node = selectedId
|
||||||
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
|
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
|
||||||
@@ -30,7 +34,6 @@ export function ItemPanel() {
|
|||||||
if (!selectedId || !node) return
|
if (!selectedId || !node) return
|
||||||
updateNode(selectedId as AnyNode['id'], updates)
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
|
||||||
// Mark parent wall as dirty if item is attached to wall
|
|
||||||
if (node.asset.attachTo === 'wall' && node.parentId) {
|
if (node.asset.attachTo === 'wall' && node.parentId) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
|
||||||
@@ -48,7 +51,6 @@ export function ItemPanel() {
|
|||||||
if (node) {
|
if (node) {
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setMovingNode(node)
|
setMovingNode(node)
|
||||||
// Deselect so the panel closes
|
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}
|
}
|
||||||
}, [node, setMovingNode, setSelection])
|
}, [node, setMovingNode, setSelection])
|
||||||
@@ -56,8 +58,6 @@ export function ItemPanel() {
|
|||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!node) return
|
if (!node) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
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({
|
const proto = ItemNode.parse({
|
||||||
position: [...node.position] as [number, number, number],
|
position: [...node.position] as [number, number, number],
|
||||||
rotation: [...node.rotation] as [number, number, number],
|
rotation: [...node.rotation] as [number, number, number],
|
||||||
@@ -78,221 +78,171 @@ export function ItemPanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [selectedId, deleteNode, setSelection])
|
}, [selectedId, deleteNode, setSelection])
|
||||||
|
|
||||||
// Only show if exactly one item is selected
|
|
||||||
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || node.asset.name}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon={node.asset.thumbnail || '/icons/furniture.png'}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image
|
width={300}
|
||||||
src={node.asset.thumbnail || '/icons/furniture.png'}
|
|
||||||
alt=""
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="shrink-0 object-contain"
|
|
||||||
/>
|
|
||||||
<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" />
|
<PanelSection title="Position">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
|
|
||||||
{/* 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}
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
|
||||||
handleUpdate({ position: [value, node.position[1], node.position[2]] })
|
min={node.position[0] - 2}
|
||||||
}}
|
max={node.position[0] + 2}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Y"
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
value={Math.round(node.position[1] * 100) / 100}
|
value={Math.round(node.position[1] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
|
||||||
handleUpdate({ position: [node.position[0], value, node.position[2]] })
|
min={node.position[1] - 2}
|
||||||
}}
|
max={node.position[1] + 2}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Z"
|
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
value={Math.round(node.position[2] * 100) / 100}
|
value={Math.round(node.position[2] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
|
||||||
handleUpdate({ position: [node.position[0], node.position[1], value] })
|
min={node.position[2] - 2}
|
||||||
}}
|
max={node.position[2] + 2}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rotation */}
|
<PanelSection title="Rotation">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||||
Rotation
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<NumberInput
|
|
||||||
label="Y"
|
|
||||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||||
onChange={(degrees) => {
|
onChange={(degrees) => {
|
||||||
const radians = (degrees * Math.PI) / 180
|
const radians = (degrees * Math.PI) / 180
|
||||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
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}
|
precision={0}
|
||||||
className="flex-1"
|
step={1}
|
||||||
|
unit="°"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||||
</div>
|
<ActionButton
|
||||||
<div className="flex gap-2">
|
label="-45°"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
const newDegrees = currentDegrees - 90
|
const radians = ((currentDegrees - 45) * Math.PI) / 180
|
||||||
const radians = (newDegrees * Math.PI) / 180
|
|
||||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
-90°
|
<ActionButton
|
||||||
</button>
|
label="+45°"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
const newDegrees = currentDegrees + 90
|
const radians = ((currentDegrees + 45) * Math.PI) / 180
|
||||||
const radians = (newDegrees * Math.PI) / 180
|
|
||||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
+90°
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Scale */}
|
<PanelSection title="Scale">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between px-2 pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
|
||||||
Scale
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
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]"
|
||||||
|
)}
|
||||||
onClick={() => setUniformScale((v) => !v)}
|
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" />}
|
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uniformScale ? (
|
{uniformScale ? (
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="XYZ"
|
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||||
value={Math.round(node.scale[0] * 100) / 100}
|
value={Math.round(node.scale[0] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const v = Math.max(0.01, value)
|
const v = Math.max(0.01, value)
|
||||||
handleUpdate({ scale: [v, v, v] })
|
handleUpdate({ scale: [v, v, v] })
|
||||||
}}
|
}}
|
||||||
|
min={0.01}
|
||||||
|
max={10}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="X"
|
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||||
value={Math.round(node.scale[0] * 100) / 100}
|
value={Math.round(node.scale[0] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
|
||||||
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
|
min={0.01}
|
||||||
}}
|
max={10}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Y"
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||||
value={Math.round(node.scale[1] * 100) / 100}
|
value={Math.round(node.scale[1] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
|
||||||
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
|
min={0.01}
|
||||||
}}
|
max={10}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Z"
|
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||||
value={Math.round(node.scale[2] * 100) / 100}
|
value={Math.round(node.scale[2] * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
|
||||||
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
|
min={0.01}
|
||||||
}}
|
max={10}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelSection>
|
||||||
|
|
||||||
{/* Dimensions (effective, read-only) */}
|
<PanelSection title="Info">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<span>Dimensions</span>
|
||||||
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)
|
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`
|
return (
|
||||||
|
<span className="font-mono text-white">
|
||||||
|
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<PanelSection title="Actions">
|
||||||
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
<ActionGroup>
|
||||||
<div className="flex gap-2">
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
<button
|
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||||
type="button"
|
<ActionButton
|
||||||
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"
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
onClick={handleMove}
|
label="Delete"
|
||||||
>
|
|
||||||
<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}
|
onClick={handleDelete}
|
||||||
>
|
className="hover:bg-red-500/20"
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
/>
|
||||||
<span>Delete</span>
|
</ActionGroup>
|
||||||
</button>
|
</PanelSection>
|
||||||
</div>
|
</PanelWrapper>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
|
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 { useCallback } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
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
|
type ReferenceNode = ScanNode | GuideNode
|
||||||
|
|
||||||
@@ -35,60 +40,60 @@ export function ReferencePanel() {
|
|||||||
const isScan = node.type === 'scan'
|
const isScan = node.type === 'scan'
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon={isScan ? undefined : undefined}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
{isScan ? (
|
width={300}
|
||||||
<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>
|
|
||||||
</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" />
|
<PanelSection title="Position">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
{/* 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) => {
|
onChange={(value) => {
|
||||||
const pos = [...node.position] as [number, number, number]
|
const pos = [...node.position] as [number, number, number]
|
||||||
pos[i] = value
|
pos[0] = value
|
||||||
handleUpdate({ position: pos })
|
handleUpdate({ position: pos })
|
||||||
}}
|
}}
|
||||||
|
min={-50}
|
||||||
|
max={50}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
))}
|
<SliderControl
|
||||||
</div>
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
{/* Rotation Y */}
|
<PanelSection title="Rotation">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||||
Rotation
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Y"
|
|
||||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||||
onChange={(degrees) => {
|
onChange={(degrees) => {
|
||||||
const radians = (degrees * Math.PI) / 180
|
const radians = (degrees * Math.PI) / 180
|
||||||
@@ -96,40 +101,27 @@ export function ReferencePanel() {
|
|||||||
rotation: [node.rotation[0], radians, node.rotation[2]],
|
rotation: [node.rotation[0], radians, node.rotation[2]],
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
precision={0}
|
precision={0}
|
||||||
className="min-w-0 flex-1"
|
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]] })}
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Scale */}
|
<PanelSection title="Scale & Opacity">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||||
Scale
|
|
||||||
</label>
|
|
||||||
<NumberInput
|
|
||||||
label="Scale"
|
|
||||||
value={Math.round(node.scale * 100) / 100}
|
value={Math.round(node.scale * 100) / 100}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
@@ -137,30 +129,22 @@ export function ReferencePanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
max={10}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Opacity */}
|
<SliderControl
|
||||||
<div className="space-y-2">
|
label="Opacity"
|
||||||
<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}
|
value={node.opacity}
|
||||||
|
onChange={(v) => handleUpdate({ opacity: v })}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
unit="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
</PanelWrapper>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback } from 'react'
|
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() {
|
export function RoofPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
|
||||||
// Get the first selected node if it's a roof
|
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
const node = selectedId
|
const node = selectedId
|
||||||
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
|
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
|
||||||
@@ -30,204 +33,137 @@ export function RoofPanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
|
|
||||||
// Only show if exactly one roof is selected
|
|
||||||
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
// Calculate total width for display
|
|
||||||
const totalWidth = node.leftWidth + node.rightWidth
|
const totalWidth = node.leftWidth + node.rightWidth
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || "Roof"}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon="/icons/roof.png"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
width={300}
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
|
||||||
{node.name || "Roof"}
|
|
||||||
</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" />
|
<PanelSection title="Dimensions">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
label="Length"
|
||||||
|
|
||||||
{/* 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}
|
value={Math.round(node.length * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ length: v })}
|
||||||
|
min={0.5}
|
||||||
|
max={20}
|
||||||
|
precision={2}
|
||||||
|
step={0.5}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
label="Height"
|
||||||
</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}
|
value={Math.round(node.height * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ height: v })}
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slope Widths */}
|
<PanelSection title="Slope Widths">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between px-2 pb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
|
||||||
<div className="flex items-center justify-between">
|
<span>Widths</span>
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<span>Total: {totalWidth.toFixed(1)}m</span>
|
||||||
Slope Widths
|
|
||||||
</label>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
Total: {totalWidth.toFixed(1)}m
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<SliderControl
|
||||||
<div className="space-y-1">
|
label="Left"
|
||||||
<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}
|
value={Math.round(node.leftWidth * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ leftWidth: v })}
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
label="Right"
|
||||||
</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}
|
value={Math.round(node.rightWidth * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ rightWidth: v })}
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rotation */}
|
<PanelSection title="Rotation">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||||
Rotation
|
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||||
</label>
|
onChange={(degrees) => {
|
||||||
<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
|
const radians = (degrees * Math.PI) / 180
|
||||||
handleUpdate({ rotation: radians })
|
handleUpdate({ rotation: radians })
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
step="1"
|
min={-180}
|
||||||
type="number"
|
max={180}
|
||||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
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 })}
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Position */}
|
<PanelSection title="Position">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
Position
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
</label>
|
onChange={(v) => {
|
||||||
<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]
|
const pos = [...node.position] as [number, number, number]
|
||||||
pos[i] = value
|
pos[0] = v
|
||||||
handleUpdate({ position: pos })
|
handleUpdate({ position: pos })
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
step="0.5"
|
min={-50}
|
||||||
type="number"
|
max={50}
|
||||||
value={Math.round(node.position[i] * 100) / 100}
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
</div>
|
<SliderControl
|
||||||
))}
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
</div>
|
value={Math.round(node.position[1] * 100) / 100}
|
||||||
</div>
|
onChange={(v) => {
|
||||||
</div>
|
const pos = [...node.position] as [number, number, number]
|
||||||
</div>
|
pos[1] = v
|
||||||
</div>
|
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 { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Plus, Trash2, X } from 'lucide-react'
|
import { Edit, Plus, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
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() {
|
export function SlabPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -16,7 +19,6 @@ export function SlabPanel() {
|
|||||||
const editingHole = useEditor((s) => s.editingHole)
|
const editingHole = useEditor((s) => s.editingHole)
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||||
|
|
||||||
// Get the first selected node if it's a slab
|
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
const node = selectedId
|
const node = selectedId
|
||||||
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
|
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
|
||||||
@@ -35,14 +37,12 @@ export function SlabPanel() {
|
|||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
}, [setSelection, setEditingHole])
|
}, [setSelection, setEditingHole])
|
||||||
|
|
||||||
// Clear hole editing state when slab is deselected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
}
|
}
|
||||||
}, [node, setEditingHole])
|
}, [node, setEditingHole])
|
||||||
|
|
||||||
// Clear hole editing state on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -52,7 +52,6 @@ export function SlabPanel() {
|
|||||||
const handleAddHole = useCallback(() => {
|
const handleAddHole = useCallback(() => {
|
||||||
if (!node || !selectedId) return
|
if (!node || !selectedId) return
|
||||||
|
|
||||||
// Calculate centroid of the slab polygon
|
|
||||||
const polygon = node.polygon
|
const polygon = node.polygon
|
||||||
let cx = 0
|
let cx = 0
|
||||||
let cz = 0
|
let cz = 0
|
||||||
@@ -63,7 +62,6 @@ export function SlabPanel() {
|
|||||||
cx /= polygon.length
|
cx /= polygon.length
|
||||||
cz /= polygon.length
|
cz /= polygon.length
|
||||||
|
|
||||||
// Create a default small rectangular hole centered at the slab's centroid
|
|
||||||
const holeSize = 0.5
|
const holeSize = 0.5
|
||||||
const newHole: Array<[number, number]> = [
|
const newHole: Array<[number, number]> = [
|
||||||
[cx - holeSize, cz - holeSize],
|
[cx - holeSize, cz - holeSize],
|
||||||
@@ -73,7 +71,6 @@ export function SlabPanel() {
|
|||||||
]
|
]
|
||||||
const currentHoles = node?.holes || []
|
const currentHoles = node?.holes || []
|
||||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||||
// Enter edit mode for the new hole
|
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||||
|
|
||||||
@@ -98,10 +95,8 @@ export function SlabPanel() {
|
|||||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only show if exactly one slab is selected
|
|
||||||
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
// Calculate approximate area from polygon
|
|
||||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||||
if (polygon.length < 3) return 0
|
if (polygon.length < 3) return 0
|
||||||
let area = 0
|
let area = 0
|
||||||
@@ -117,159 +112,82 @@ export function SlabPanel() {
|
|||||||
const area = calculateArea(node.polygon)
|
const area = calculateArea(node.polygon)
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || "Slab"}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon="/icons/floor.png"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
width={320}
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
|
||||||
{node.name || "Slab"}
|
|
||||||
</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" />
|
<PanelSection title="Elevation">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
label="Height"
|
||||||
|
|
||||||
{/* 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}
|
value={Math.round(node.elevation * 1000) / 1000}
|
||||||
onChange={(value) => {
|
onChange={(v) => handleUpdate({ elevation: v })}
|
||||||
handleUpdate({ elevation: value })
|
min={-1}
|
||||||
}}
|
max={1}
|
||||||
precision={3}
|
precision={3}
|
||||||
className="flex-1"
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<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="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
|
||||||
<div className="space-y-2">
|
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
|
||||||
Presets
|
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
|
||||||
</label>
|
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
|
||||||
<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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Area info */}
|
<PanelSection title="Info">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<span>Area</span>
|
||||||
Area
|
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
||||||
</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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
{/* Holes */}
|
<PanelSection title="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 ? (
|
{node.holes && node.holes.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-1 pb-2">
|
||||||
{node.holes.map((hole, index) => {
|
{node.holes.map((hole, index) => {
|
||||||
const holeArea = calculateArea(hole)
|
const holeArea = calculateArea(hole)
|
||||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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 ${
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||||
isEditing
|
isEditing
|
||||||
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
|
? 'border-primary/50 bg-primary/10'
|
||||||
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
|
: 'border-transparent hover:bg-accent/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
|
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
|
||||||
Hole {index + 1} {isEditing && '(Editing)'}
|
Hole {index + 1} {isEditing && '(Editing)'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{holeArea.toFixed(2)} m² · {hole.length} vertices
|
{holeArea.toFixed(2)} m² · {hole.length} pts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{!isEditing && (
|
{isEditing ? (
|
||||||
|
<ActionButton
|
||||||
|
label="Done"
|
||||||
|
onClick={() => setEditingHole(null)}
|
||||||
|
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
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)}
|
onClick={() => handleEditHole(index)}
|
||||||
aria-label="Edit hole"
|
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
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)}
|
onClick={() => handleDeleteHole(index)}
|
||||||
aria-label="Delete hole"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -281,13 +199,21 @@ export function SlabPanel() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||||
No holes. Click "Add Hole" to create one.
|
No holes
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
</PanelWrapper>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback } from 'react'
|
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() {
|
export function WallPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -41,68 +42,41 @@ export function WallPanel() {
|
|||||||
const thickness = node.thickness ?? 0.1
|
const thickness = node.thickness ?? 0.1
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || "Wall"}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon="/icons/wall.png"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
width={280}
|
||||||
<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" />
|
<PanelSection title="Dimensions">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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"
|
label="Height"
|
||||||
value={Math.round(height * 100) / 100}
|
value={Math.round(height * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||||
min={0.1}
|
min={0.1}
|
||||||
|
max={6}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Thickness"
|
label="Thickness"
|
||||||
value={Math.round(thickness * 1000) / 1000}
|
value={Math.round(thickness * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||||
min={0.05}
|
min={0.05}
|
||||||
|
max={1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
<PanelSection title="Info">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
<span>Length</span>
|
||||||
Info
|
<span className="font-mono text-white">{length.toFixed(2)} m</span>
|
||||||
</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>
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
|
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import useEditor from '@/store/use-editor'
|
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() {
|
export function WindowPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -92,7 +96,6 @@ export function WindowPanel() {
|
|||||||
const numCols = node.columnRatios.length
|
const numCols = node.columnRatios.length
|
||||||
const numRows = node.rowRatios.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 colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
||||||
const normCols = node.columnRatios.map(r => r / colSum)
|
const normCols = node.columnRatios.map(r => r / colSum)
|
||||||
@@ -125,127 +128,91 @@ export function WindowPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<PanelWrapper
|
||||||
{/* Header */}
|
title={node.name || "Window"}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
icon="/icons/window.png"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
onClose={handleClose}
|
||||||
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
width={320}
|
||||||
<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" />
|
<PanelSection title="Position">
|
||||||
</button>
|
<SliderControl
|
||||||
</div>
|
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
|
|
||||||
{/* 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}
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||||
|
min={-10}
|
||||||
|
max={10}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Y"
|
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||||
value={Math.round(node.position[1] * 100) / 100}
|
value={Math.round(node.position[1] * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||||
|
min={-10}
|
||||||
|
max={10}
|
||||||
precision={2}
|
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}
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</PanelSection>
|
||||||
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"
|
|
||||||
onClick={handleFlip}
|
|
||||||
>
|
|
||||||
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
|
||||||
Flip Side
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dimensions */}
|
<PanelSection title="Dimensions">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<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"
|
label="Width"
|
||||||
value={Math.round(node.width * 100) / 100}
|
value={Math.round(node.width * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ width: v })}
|
onChange={(v) => handleUpdate({ width: v })}
|
||||||
min={0.2}
|
min={0.2}
|
||||||
|
max={5}
|
||||||
precision={2}
|
precision={2}
|
||||||
className="flex-1"
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Height"
|
label="Height"
|
||||||
value={Math.round(node.height * 100) / 100}
|
value={Math.round(node.height * 100) / 100}
|
||||||
onChange={(v) => handleUpdate({ height: v })}
|
onChange={(v) => handleUpdate({ height: v })}
|
||||||
min={0.2}
|
min={0.2}
|
||||||
|
max={5}
|
||||||
precision={2}
|
precision={2}
|
||||||
className="flex-1"
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Frame */}
|
<PanelSection title="Frame">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<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"
|
label="Thickness"
|
||||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
max={0.2}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Depth"
|
label="Depth"
|
||||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
max={0.3}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
<PanelSection title="Grid">
|
||||||
<div className="space-y-2">
|
<SliderControl
|
||||||
<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"
|
label="Columns"
|
||||||
value={numCols}
|
value={numCols}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -257,7 +224,7 @@ export function WindowPanel() {
|
|||||||
precision={0}
|
precision={0}
|
||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Rows"
|
label="Rows"
|
||||||
value={numRows}
|
value={numRows}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -269,15 +236,13 @@ export function WindowPanel() {
|
|||||||
precision={0}
|
precision={0}
|
||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column ratios */}
|
|
||||||
{numCols > 1 && (
|
{numCols > 1 && (
|
||||||
<div className="space-y-1">
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
<span className="text-muted-foreground text-xs">Column widths</span>
|
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
|
||||||
{normCols.map((ratio, i) => (
|
{normCols.map((ratio, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
key={`c-${i}`}
|
||||||
label={`C${i + 1}`}
|
label={`C${i + 1}`}
|
||||||
value={Math.round(ratio * 100 * 10) / 10}
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||||
@@ -285,33 +250,30 @@ export function WindowPanel() {
|
|||||||
max={95}
|
max={95}
|
||||||
precision={1}
|
precision={1}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-1"
|
unit="%"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="mt-1 border-t border-border/50 pt-1">
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Col divider"
|
label="Divider"
|
||||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||||
min={0.005}
|
min={0.005}
|
||||||
|
max={0.1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row ratios */}
|
|
||||||
{numRows > 1 && (
|
{numRows > 1 && (
|
||||||
<div className="space-y-1">
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
<span className="text-muted-foreground text-xs">Row heights</span>
|
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
|
||||||
{normRows.map((ratio, i) => (
|
{normRows.map((ratio, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
key={`r-${i}`}
|
||||||
label={`R${i + 1}`}
|
label={`R${i + 1}`}
|
||||||
value={Math.round(ratio * 100 * 10) / 10}
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
onChange={(v) => setRowRatio(i, v / 100)}
|
onChange={(v) => setRowRatio(i, v / 100)}
|
||||||
@@ -319,98 +281,69 @@ export function WindowPanel() {
|
|||||||
max={95}
|
max={95}
|
||||||
precision={1}
|
precision={1}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-1"
|
unit="%"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="mt-1 border-t border-border/50 pt-1">
|
||||||
<NumberInput
|
<SliderControl
|
||||||
label="Row divider"
|
label="Divider"
|
||||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||||
min={0.005}
|
min={0.005}
|
||||||
|
max={0.1}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelSection>
|
||||||
|
|
||||||
{/* Sill */}
|
<PanelSection title="Sill">
|
||||||
<div className="space-y-2">
|
<ToggleControl
|
||||||
<div className="flex items-center justify-between">
|
label="Enable Sill"
|
||||||
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
|
|
||||||
Sill
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
checked={node.sill}
|
checked={node.sill}
|
||||||
onCheckedChange={(checked) => handleUpdate({ sill: checked })}
|
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{node.sill && (
|
{node.sill && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<SliderControl
|
||||||
<NumberInput
|
|
||||||
label="Depth"
|
label="Depth"
|
||||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
max={0.5}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
<SliderControl
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NumberInput
|
|
||||||
label="Thickness"
|
label="Thickness"
|
||||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||||
min={0.005}
|
min={0.005}
|
||||||
|
max={0.2}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="flex-1"
|
unit="m"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<PanelSection title="Actions">
|
||||||
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
|
<ActionGroup>
|
||||||
<div className="flex gap-2">
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
<button
|
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||||
type="button"
|
<ActionButton
|
||||||
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"
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
onClick={handleMove}
|
label="Delete"
|
||||||
>
|
|
||||||
<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}
|
onClick={handleDelete}
|
||||||
>
|
className="hover:bg-red-500/20"
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
/>
|
||||||
<span>Delete</span>
|
</ActionGroup>
|
||||||
</button>
|
</PanelSection>
|
||||||
</div>
|
</PanelWrapper>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function AppSidebar() {
|
|||||||
|
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<button
|
<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')}
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
@@ -139,7 +139,7 @@ export function AppSidebar() {
|
|||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
{/* Sliding Background */}
|
{/* Sliding Background */}
|
||||||
<motion.div
|
<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}
|
initial={false}
|
||||||
animate={{
|
animate={{
|
||||||
x: theme === "light" ? "100%" : "0%",
|
x: theme === "light" ? "100%" : "0%",
|
||||||
|
|||||||
@@ -779,13 +779,13 @@ function LayerToggle() {
|
|||||||
phase === "structure" && structureLayer === "zones" ? "zones" : "none";
|
phase === "structure" && structureLayer === "zones" ? "zones" : "none";
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
className={cn(
|
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",
|
"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"
|
activeTab === "structure"
|
||||||
? "text-foreground"
|
? "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={() => {
|
onClick={() => {
|
||||||
setPhase("structure");
|
setPhase("structure");
|
||||||
@@ -795,7 +795,7 @@ function LayerToggle() {
|
|||||||
{activeTab === "structure" && (
|
{activeTab === "structure" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="layerToggleActiveBg"
|
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 }}
|
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",
|
"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"
|
activeTab === "furnish"
|
||||||
? "text-foreground"
|
? "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={() => {
|
onClick={() => {
|
||||||
setPhase("furnish");
|
setPhase("furnish");
|
||||||
@@ -828,7 +828,7 @@ function LayerToggle() {
|
|||||||
{activeTab === "furnish" && (
|
{activeTab === "furnish" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="layerToggleActiveBg"
|
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 }}
|
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",
|
"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"
|
activeTab === "zones"
|
||||||
? "text-foreground"
|
? "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={() => {
|
onClick={() => {
|
||||||
setPhase("structure");
|
setPhase("structure");
|
||||||
@@ -862,7 +862,7 @@ function LayerToggle() {
|
|||||||
{activeTab === "zones" && (
|
{activeTab === "zones" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="layerToggleActiveBg"
|
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 }}
|
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user