feat(panels): conditional property panel, layer expand/collapse and scoped hover

- property panel only renders when an element is selected
- layer items show eye/lock icons only on the hovered row (group/layer scope)
- replace drag handle with chevron expand/collapse for nodes with children
- track collapsed state in LayerPanel, default all expanded
- remove uppercase from panel section headers
This commit is contained in:
Fini 2026-02-18 23:42:16 +08:00
parent f85bbe69dc
commit 892cc789e1
4 changed files with 88 additions and 62 deletions

View file

@ -19,7 +19,7 @@ export default function CornerRadiusSection({
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<h4 className="text-xs font-medium text-muted-foreground tracking-wider">
Corner Radius
</h4>
<NumberInput

View file

@ -13,8 +13,9 @@ import {
Hexagon,
Spline,
Link,
GripVertical,
ImageIcon,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import type { PenNodeType } from '@/types/pen'
@ -39,10 +40,13 @@ interface LayerItemProps {
selected: boolean
visible: boolean
locked: boolean
hasChildren: boolean
expanded: boolean
onSelect: (id: string) => void
onRename: (id: string, name: string) => void
onToggleVisibility: (id: string) => void
onToggleLock: (id: string) => void
onToggleExpand: (id: string) => void
onContextMenu: (e: React.MouseEvent, id: string) => void
onDragStart: (id: string) => void
onDragOver: (id: string) => void
@ -57,10 +61,13 @@ export default function LayerItem({
selected,
visible,
locked,
hasChildren,
expanded,
onSelect,
onRename,
onToggleVisibility,
onToggleLock,
onToggleExpand,
onContextMenu,
onDragStart,
onDragOver,
@ -91,15 +98,13 @@ export default function LayerItem({
}
}
const handlePointerDown = (e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('[data-drag-handle]')) {
onDragStart(id)
}
const handlePointerDown = () => {
onDragStart(id)
}
return (
<div
className={`flex items-center h-7 px-1 gap-1 cursor-pointer rounded text-xs transition-colors ${
className={`group/layer flex items-center h-7 px-1 gap-1 cursor-pointer rounded text-xs transition-colors ${
selected
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-accent/50'
@ -112,12 +117,20 @@ export default function LayerItem({
onPointerEnter={() => onDragOver(id)}
onPointerUp={onDragEnd}
>
<div
data-drag-handle
className="cursor-grab opacity-0 group-hover:opacity-60 hover:opacity-100 shrink-0"
>
<GripVertical size={10} />
</div>
{hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleExpand(id)
}}
className="shrink-0 opacity-60 hover:opacity-100"
>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : (
<span className="shrink-0 w-3" />
)}
<Icon size={12} className="shrink-0 opacity-60" />
@ -144,7 +157,7 @@ export default function LayerItem({
className={`p-0.5 transition-opacity ${
!visible
? 'opacity-100 text-yellow-400'
: 'opacity-0 group-hover:opacity-100'
: 'opacity-0 group-hover/layer:opacity-100'
}`}
title={visible ? 'Hide' : 'Show'}
>
@ -159,7 +172,7 @@ export default function LayerItem({
className={`p-0.5 transition-opacity ${
locked
? 'opacity-100 text-orange-400'
: 'opacity-0 group-hover:opacity-100'
: 'opacity-0 group-hover/layer:opacity-100'
}`}
title={locked ? 'Unlock' : 'Lock'}
>

View file

@ -20,45 +20,57 @@ function renderLayerTree(
onRename: (id: string, name: string) => void
onToggleVisibility: (id: string) => void
onToggleLock: (id: string) => void
onToggleExpand: (id: string) => void
onContextMenu: (e: React.MouseEvent, id: string) => void
onDragStart: (id: string) => void
onDragOver: (id: string) => void
onDragEnd: () => void
},
dragOverId: string | null,
collapsedIds: Set<string>,
) {
return [...nodes].reverse().map((node) => (
<div key={node.id} className="group">
<div
className={
dragOverId === node.id
? 'border-t-2 border-blue-500'
: 'border-t-2 border-transparent'
}
>
<LayerItem
id={node.id}
name={node.name ?? node.type}
type={node.type}
depth={depth}
selected={selectedIds.includes(node.id)}
visible={node.visible !== false}
locked={node.locked === true}
{...handlers}
/>
return [...nodes].reverse().map((node) => {
const nodeChildren =
'children' in node && node.children && node.children.length > 0
? node.children
: null
const isExpanded = !collapsedIds.has(node.id)
return (
<div key={node.id}>
<div
className={
dragOverId === node.id
? 'border-t-2 border-blue-500'
: 'border-t-2 border-transparent'
}
>
<LayerItem
id={node.id}
name={node.name ?? node.type}
type={node.type}
depth={depth}
selected={selectedIds.includes(node.id)}
visible={node.visible !== false}
locked={node.locked === true}
hasChildren={nodeChildren !== null}
expanded={isExpanded}
{...handlers}
/>
</div>
{nodeChildren &&
isExpanded &&
renderLayerTree(
nodeChildren,
depth + 1,
selectedIds,
handlers,
dragOverId,
collapsedIds,
)}
</div>
{'children' in node &&
node.children &&
node.children.length > 0 &&
renderLayerTree(
node.children,
depth + 1,
selectedIds,
handlers,
dragOverId,
)}
</div>
))
)
})
}
export default function LayerPanel() {
@ -83,6 +95,19 @@ export default function LayerPanel() {
const dragRef = useRef<DragState>({ dragId: null, overId: null })
const [dragOverId, setDragOverId] = useState<string | null>(null)
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set())
const handleToggleExpand = useCallback((id: string) => {
setCollapsedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const handleSelect = useCallback(
(id: string) => {
@ -190,6 +215,7 @@ export default function LayerPanel() {
onRename: handleRename,
onToggleVisibility: toggleVisibility,
onToggleLock: toggleLock,
onToggleExpand: handleToggleExpand,
onContextMenu: handleContextMenu,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
@ -199,7 +225,7 @@ export default function LayerPanel() {
return (
<div className="w-56 bg-card border-r border-border flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-border">
<span className="text-xs font-medium text-muted-foreground tracking-wider">
<span className="text-xs font-medium text-muted-foreground tracking-wider">
Layers
</span>
</div>
@ -209,7 +235,7 @@ export default function LayerPanel() {
No layers yet. Use the toolbar to draw shapes.
</p>
) : (
renderLayerTree(children, 0, selectedIds, handlers, dragOverId)
renderLayerTree(children, 0, selectedIds, handlers, dragOverId, collapsedIds)
)}
</div>

View file

@ -26,20 +26,7 @@ export default function PropertyPanel() {
}
if (!node) {
return (
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-border">
<span className="text-[11px] font-medium text-muted-foreground">
Design
</span>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-[11px] text-muted-foreground px-4 text-center">
Select an element to view its properties.
</p>
</div>
</div>
)
return null
}
const hasFill =