Skip to content

Commit 5d5ea6f

Browse files
feat: add properties panel with inspector and tabs
- Introduced a new PropertiesPanel component with a tabbed interface for managing object properties. - Implemented InspectorPanel to display and edit properties of the selected object. - Added ScrollAreaHorizontal for horizontal scrolling of tabs. - Integrated DragInput for numeric input handling in the inspector. - Updated Zustand store for managing active properties tab state. - Added clsx and tailwind-merge for utility class management. - Enhanced global styles with custom scrollbar styles. - Updated layout to include the new properties panel on the right side.
1 parent 141c986 commit 5d5ea6f

File tree

13 files changed

+553
-67
lines changed

13 files changed

+553
-67
lines changed

ai-log/agent-conversations/15.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Adding the properties panel on the right side
2+
3+
So far so good, we've got a basic 3d editor with advanced editing features, but we're still missing a lot of the things that allow us to modify the scene, such as the properties panel that editor like blender have.
4+
5+
Task: Add a floating properties panel to the right side. It should have a tab bar on the top (which is horizontally scrollable) that allows to switch between different properties panels (for now leave all of them empty except the inspector panel).
6+
7+
Panels:
8+
- Object Inspector: Displays properties for the selected object, including transform data (location, rotation, scale), visibility, and instancing options.
9+
10+
- Scene Properties: Contains settings for the overall scene, such as units, gravity, and scene-wide options like render engines or view layer settings.
11+
12+
- View Layer: Manages view layer settings, including layer visibility, render passes, and compositing options.
13+
14+
- World: Controls the environment settings, such as background lighting, environment textures, and world shaders.
15+
16+
- Object Modifiers: Manages modifiers applied to the selected object, like Subdivision Surface, Mirror, or Array.
17+
18+
- Object Data Properties: Object Data Properties (icon: varies based on object type, e.g., mesh, curve, or light)
19+
Contains settings specific to the object’s data type (e.g., mesh vertices, curve settings, or light properties).
20+
21+
- Material: Manages material settings for the selected object, including shader nodes, textures, and surface properties.
22+
23+
- Render Properties: Contains settings for rendering, including resolution, sampling, and output options
24+
25+
- Output: Manages output settings, such as file format, resolution, and output path for renders.
26+
27+
State Management:
28+
- as always, everything flows as much as possible through the zustand stores!
29+
30+
Notes:
31+
- Make sure to provide a robust foundation by splitting the code feature wise in a hierarchical folder layout. Write small focused react components instead of big all-in-one files.
32+
33+
- The renderer is threejs, so most properties will align with whatever threejs allows

ai-log/agent-conversations/16.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Vertical 1: Scene Hierarchy
2+
We've got most of the basic functionality as well as some advanced editing tools, now it's time to dive into some of the verticals to ensure they work properly and smoothly
3+
4+
This will blow quite a few prompts but it's good at this stage to not mix up the context with horizontal features.

package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
"@react-three/fiber": "^9.3.0",
1515
"@types/jszip": "^3.4.0",
1616
"@types/three": "^0.179.0",
17+
"clsx": "^2.1.1",
1718
"immer": "^10.1.1",
1819
"jszip": "^3.10.1",
1920
"lucide-react": "^0.539.0",
2021
"nanoid": "^5.1.5",
2122
"next": "15.4.6",
2223
"react": "19.1.0",
2324
"react-dom": "19.1.0",
25+
"tailwind-merge": "^3.3.1",
2426
"three": "^0.179.1",
2527
"zustand": "^5.0.7"
2628
},

src/app/globals.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,31 @@ body {
2424
color: var(--foreground);
2525
font-family: Arial, Helvetica, sans-serif;
2626
}
27+
28+
/* Dark, slim scrollbars across the app */
29+
/* Firefox */
30+
* {
31+
scrollbar-width: thin;
32+
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
33+
}
34+
35+
/* WebKit (Chrome, Safari, Edge) */
36+
*::-webkit-scrollbar {
37+
width: 8px;
38+
height: 8px;
39+
}
40+
41+
*::-webkit-scrollbar-track {
42+
background: transparent;
43+
}
44+
45+
*::-webkit-scrollbar-thumb {
46+
background-color: rgba(255, 255, 255, 0.18);
47+
border-radius: 9999px;
48+
border: 2px solid transparent; /* inner padding for a slimmer look */
49+
background-clip: content-box;
50+
}
51+
52+
*::-webkit-scrollbar-thumb:hover {
53+
background-color: rgba(255, 255, 255, 0.28);
54+
}

src/components/drag-input.tsx

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { cn } from '@/utils/tailwind'
2+
import React, { useState, useRef, useCallback, useEffect } from 'react'
3+
4+
interface DragInputProps {
5+
value?: number
6+
onChange: (value: number) => void
7+
step?: number
8+
precision?: number
9+
min?: number
10+
id?: string
11+
max?: number
12+
className?: string
13+
label?: string
14+
suffix?: string
15+
disabled?: boolean
16+
compact?: boolean
17+
}
18+
19+
export function DragInput({
20+
value,
21+
onChange,
22+
step = 0.01,
23+
precision = 1,
24+
min,
25+
max,
26+
id,
27+
className,
28+
label,
29+
suffix,
30+
disabled = false,
31+
compact = false
32+
}: DragInputProps) {
33+
const [isDragging, setIsDragging] = useState(false)
34+
const [hasDragged, setHasDragged] = useState(false)
35+
const [isEditing, setIsEditing] = useState(false)
36+
const [inputValue, setInputValue] = useState(value?.toFixed(precision) ?? '')
37+
const [dragStartX, setDragStartX] = useState(0)
38+
const [dragStartValue, setDragStartValue] = useState(0)
39+
const inputRef = useRef<HTMLInputElement>(null)
40+
const displayRef = useRef<HTMLDivElement>(null)
41+
42+
useEffect(() => {
43+
// Don't update input value while actively dragging
44+
// This prevents external value changes from breaking the drag state
45+
if (!isEditing && !isDragging && value !== undefined) {
46+
setInputValue(value.toFixed(precision))
47+
}
48+
}, [value, precision, isEditing, isDragging])
49+
50+
// Handle cursor style and iframe blocking
51+
useEffect(() => {
52+
if (isDragging) {
53+
document.body.style.cursor = 'ew-resize'
54+
// Disable pointer events on all iframes during drag
55+
const iframes = document.querySelectorAll('iframe')
56+
iframes.forEach(iframe => {
57+
iframe.style.pointerEvents = 'none'
58+
})
59+
60+
return () => {
61+
document.body.style.cursor = 'default'
62+
// Re-enable pointer events on iframes
63+
iframes.forEach(iframe => {
64+
iframe.style.pointerEvents = 'auto'
65+
})
66+
}
67+
}
68+
}, [isDragging])
69+
70+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
71+
if (isEditing || disabled) return
72+
73+
setIsDragging(true)
74+
setHasDragged(false)
75+
setDragStartX(e.clientX)
76+
setDragStartValue(value ?? 0)
77+
78+
e.preventDefault()
79+
}, [isEditing, disabled, value])
80+
81+
const handleMouseMove = useCallback((e: MouseEvent) => {
82+
if (!isDragging) return
83+
84+
const deltaX = e.clientX - dragStartX
85+
86+
// Mark that we've actually dragged if moved more than 2 pixels
87+
if (Math.abs(deltaX) > 2) {
88+
setHasDragged(true)
89+
}
90+
91+
const deltaValue = deltaX * step
92+
let newValue = dragStartValue + deltaValue
93+
94+
if (min !== undefined) newValue = Math.max(min, newValue)
95+
if (max !== undefined) newValue = Math.min(max, newValue)
96+
97+
onChange(newValue)
98+
}, [isDragging, step, min, max, onChange, dragStartX, dragStartValue])
99+
100+
const handleMouseUp = useCallback(() => {
101+
setIsDragging(false)
102+
// Reset accumulated delta after a short delay to allow click detection
103+
setTimeout(() => {
104+
setHasDragged(false)
105+
}, 0)
106+
}, [])
107+
108+
useEffect(() => {
109+
if (isDragging) {
110+
document.addEventListener('mousemove', handleMouseMove)
111+
document.addEventListener('mouseup', handleMouseUp)
112+
return () => {
113+
document.removeEventListener('mousemove', handleMouseMove)
114+
document.removeEventListener('mouseup', handleMouseUp)
115+
}
116+
}
117+
}, [isDragging, handleMouseMove, handleMouseUp])
118+
119+
const toggleEditing = () => {
120+
if (disabled) return
121+
122+
if (isEditing) {
123+
handleInputBlur()
124+
} else {
125+
setIsEditing(true)
126+
setTimeout(() => {
127+
inputRef.current?.focus()
128+
inputRef.current?.select()
129+
}, 0)
130+
}
131+
}
132+
133+
const handleClick = () => {
134+
// Only allow editing if we didn't actually drag
135+
if (!hasDragged && !disabled) {
136+
toggleEditing()
137+
}
138+
}
139+
140+
const handleKeyDown = (e: React.KeyboardEvent) => {
141+
if (disabled) return
142+
143+
if (e.key === 'Enter') {
144+
e.preventDefault()
145+
toggleEditing()
146+
} else if (e.key === ' ') {
147+
e.preventDefault()
148+
toggleEditing()
149+
} else if (/^[0-9]$/.test(e.key)) {
150+
// Start editing and replace the current value with the typed digit
151+
e.preventDefault()
152+
setIsEditing(true)
153+
setInputValue(e.key)
154+
setTimeout(() => {
155+
inputRef.current?.focus()
156+
// Position cursor at the end
157+
inputRef.current?.setSelectionRange(1, 1)
158+
}, 0)
159+
}
160+
}
161+
162+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
163+
setInputValue(e.target.value)
164+
}
165+
166+
const handleInputBlur = () => {
167+
const numValue = parseFloat(inputValue)
168+
if (!isNaN(numValue)) {
169+
let finalValue = numValue
170+
if (min !== undefined) finalValue = Math.max(min, finalValue)
171+
if (max !== undefined) finalValue = Math.min(max, finalValue)
172+
onChange(finalValue)
173+
}
174+
setIsEditing(false)
175+
}
176+
177+
const handleInputKeyDown = (e: React.KeyboardEvent) => {
178+
if (e.key === 'Enter') {
179+
e.preventDefault()
180+
handleInputBlur()
181+
} else if (e.key === 'Escape') {
182+
setInputValue(value?.toFixed(precision) ?? '')
183+
setIsEditing(false)
184+
}
185+
}
186+
187+
return (
188+
<div className={cn("flex items-center gap-1 w-full min-w-0 overflow-hidden", className)}>
189+
{label && (
190+
<span className={cn("text-xs text-zinc-400 flex-shrink-0", compact ? "min-w-0" : "min-w-[40px]")}>{label}</span>
191+
)}
192+
{isEditing ? (
193+
<input
194+
id={id}
195+
ref={inputRef}
196+
type="text"
197+
value={inputValue}
198+
onChange={handleInputChange}
199+
onBlur={handleInputBlur}
200+
onKeyDown={handleInputKeyDown}
201+
disabled={disabled}
202+
className={`flex-1 h-6 px-2 text-xs border rounded focus:outline-none min-w-0 w-0 ${
203+
disabled
204+
? 'bg-zinc-800/50 border-zinc-700/30 text-zinc-500 cursor-not-allowed'
205+
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300 focus:border-emerald-500'
206+
}`}
207+
/>
208+
) : (
209+
<div
210+
ref={displayRef}
211+
tabIndex={disabled ? -1 : 0}
212+
className={cn(
213+
"flex-1 h-6 px-2 text-xs border rounded flex items-center justify-between transition-colors select-none min-w-0",
214+
disabled
215+
? "bg-zinc-800/50 border-zinc-700/30 text-zinc-500 cursor-not-allowed"
216+
: "bg-black/20 border-zinc-700/50 text-zinc-300 cursor-ew-resize hover:border-emerald-500/30 focus:border-emerald-500/50 focus:outline-none",
217+
isDragging && !disabled && "bg-emerald-500/10 border-emerald-500/30"
218+
)}
219+
onMouseDown={handleMouseDown}
220+
onClick={handleClick}
221+
onKeyDown={handleKeyDown}
222+
>
223+
<span className="truncate">{value?.toFixed(precision) ?? ''}</span>
224+
{suffix && <span className="text-zinc-500 flex-shrink-0 ml-1">{suffix}</span>}
225+
</div>
226+
)}
227+
</div>
228+
)
229+
}

src/features/layout/components/editor-layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { EditToolsToolbar } from '@/features/toolbar';
88
import { SelectionSummary } from '@/features/toolbar/components/selection-summary';
99
import { ToolIndicator } from '@/features/tools';
1010
import { EditorViewport } from '@/features/viewport';
11+
import { PropertiesPanel } from '@/features/properties-panel/components/properties-panel';
1112
import React from 'react';
1213

1314
const EditorLayout: React.FC = () => {
@@ -28,11 +29,16 @@ const EditorLayout: React.FC = () => {
2829
<EditToolsToolbar />
2930
</div>
3031

31-
{/* Right Scene Hierarchy Panel */}
32+
{/* Left Scene Hierarchy Panel */}
3233
<div className="absolute left-4 top-32 z-20">
3334
<SceneHierarchyPanel />
3435
</div>
3536

37+
{/* Right Properties Panel */}
38+
<div className="absolute right-4 top-32 z-20">
39+
<PropertiesPanel />
40+
</div>
41+
3642
{/* Bottom-left selection summary */}
3743
<div className="absolute left-4 bottom-4 z-20 max-w-md">
3844
<div className="bg-black/30 backdrop-blur-sm rounded-md border border-white/10 p-3">

0 commit comments

Comments
 (0)