feat(panels): add component/instance UI to layer and property panels

layer panel shows diamond icon and purple/#9281f7 styling for
components/instances with context menu actions; property panel resolves
RefNode display, routes instance overrides, adds header with go-to and
detach buttons, and supports inline name editing on click
This commit is contained in:
Fini 2026-02-21 04:47:03 +08:00
parent a4d6e11aad
commit 0c0ed93ae8
4 changed files with 328 additions and 48 deletions

View file

@ -5,6 +5,8 @@ import {
Group,
Lock,
EyeOff,
Component,
Unlink,
} from 'lucide-react'
interface LayerContextMenuProps {
@ -12,6 +14,9 @@ interface LayerContextMenuProps {
y: number
nodeId: string
canGroup: boolean
canCreateComponent: boolean
isReusable: boolean
isInstance: boolean
onAction: (action: string) => void
onClose: () => void
}
@ -20,6 +25,9 @@ const MENU_ITEMS = [
{ action: 'duplicate', label: 'Duplicate', icon: Copy },
{ action: 'delete', label: 'Delete', icon: Trash2 },
{ action: 'group', label: 'Group Selection', icon: Group, requireGroup: true },
{ action: 'make-component', label: 'Create Component', icon: Component, requireCreateComponent: true },
{ action: 'detach-component', label: 'Detach Component', icon: Unlink, requireReusable: true },
{ action: 'detach-component', label: 'Detach Instance', icon: Unlink, requireInstance: true },
{ action: 'lock', label: 'Toggle Lock', icon: Lock },
{ action: 'hide', label: 'Toggle Visibility', icon: EyeOff },
]
@ -28,6 +36,9 @@ export default function LayerContextMenu({
x,
y,
canGroup,
canCreateComponent,
isReusable,
isInstance,
onAction,
onClose,
}: LayerContextMenuProps) {
@ -57,7 +68,11 @@ export default function LayerContextMenu({
style={{ left: x, top: y }}
>
{MENU_ITEMS.filter(
(item) => !item.requireGroup || canGroup,
(item) =>
(!item.requireGroup || canGroup) &&
(!('requireCreateComponent' in item) || canCreateComponent) &&
(!('requireReusable' in item) || isReusable) &&
(!('requireInstance' in item) || isInstance),
).map((item) => (
<button
key={item.action}

View file

@ -16,6 +16,7 @@ import {
ImageIcon,
ChevronDown,
ChevronRight,
Diamond,
} from 'lucide-react'
import type { PenNodeType } from '@/types/pen'
@ -44,6 +45,8 @@ interface LayerItemProps {
locked: boolean
hasChildren: boolean
expanded: boolean
isReusable: boolean
isInstance: boolean
dropPosition: DropPosition
onSelect: (id: string) => void
onRename: (id: string, name: string) => void
@ -66,6 +69,8 @@ export default function LayerItem({
locked,
hasChildren,
expanded,
isReusable,
isInstance,
dropPosition,
onSelect,
onRename,
@ -80,7 +85,7 @@ export default function LayerItem({
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState(name)
const Icon = TYPE_ICONS[type] ?? Square
const Icon = isReusable || isInstance ? Diamond : (TYPE_ICONS[type] ?? Square)
const handleDoubleClick = () => {
setEditName(name)
@ -120,8 +125,16 @@ export default function LayerItem({
<div
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'
? isReusable
? 'bg-purple-500/15 text-purple-400'
: isInstance
? 'bg-[#9281f7]/10 text-[#9281f7]'
: 'bg-primary/15 text-primary'
: isReusable
? 'text-purple-400 hover:bg-purple-500/10'
: isInstance
? 'text-[#9281f7] hover:bg-[#9281f7]/10'
: 'text-muted-foreground hover:bg-accent/50'
} ${!visible ? 'opacity-40' : ''} ${dropInsideHighlight}`}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={() => onSelect(id)}
@ -146,7 +159,7 @@ export default function LayerItem({
<span className="shrink-0 w-3" />
)}
<Icon size={12} className="shrink-0 opacity-60" />
<Icon size={12} className={`shrink-0 ${isReusable ? 'text-purple-400' : isInstance ? 'text-[#9281f7]' : 'opacity-60'}`} />
{isEditing ? (
<input

View file

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react'
import { useDocumentStore } from '@/stores/document-store'
import { useDocumentStore, findNodeInTree } from '@/stores/document-store'
import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
import type { PenNode } from '@/types/pen'
@ -15,6 +15,28 @@ interface DragState {
dropPosition: DropPosition
}
function isNodeReusable(node: PenNode, parentReusable: boolean): boolean {
if (parentReusable) return true
return 'reusable' in node && node.reusable === true
}
/** Get effective children for a node, resolving RefNode instances. */
function getEffectiveChildren(
node: PenNode,
allChildren: PenNode[],
): PenNode[] | null {
if (node.type === 'ref') {
const component = findNodeInTree(allChildren, node.ref)
if (component && 'children' in component && component.children?.length) {
return component.children
}
return null
}
return 'children' in node && node.children && node.children.length > 0
? node.children
: null
}
function renderLayerTree(
nodes: PenNode[],
depth: number,
@ -33,14 +55,16 @@ function renderLayerTree(
dragOverId: string | null,
dropPosition: DropPosition,
collapsedIds: Set<string>,
allChildren: PenNode[],
parentReusable = false,
parentIsInstance = false,
) {
return [...nodes].reverse().map((node) => {
const nodeChildren =
'children' in node && node.children && node.children.length > 0
? node.children
: null
const nodeChildren = getEffectiveChildren(node, allChildren)
const isExpanded = !collapsedIds.has(node.id)
const isDropTarget = dragOverId === node.id
const isInstance = node.type === 'ref' || parentIsInstance
const reusable = isNodeReusable(node, parentReusable)
return (
<div key={node.id}>
@ -54,6 +78,8 @@ function renderLayerTree(
locked={node.locked === true}
hasChildren={nodeChildren !== null}
expanded={isExpanded}
isReusable={reusable}
isInstance={isInstance}
dropPosition={isDropTarget ? dropPosition : null}
{...handlers}
/>
@ -67,6 +93,9 @@ function renderLayerTree(
dragOverId,
dropPosition,
collapsedIds,
allChildren,
reusable,
isInstance,
)}
</div>
)
@ -214,6 +243,9 @@ export default function LayerPanel() {
setDropPosition(null)
}, [children, getParentOf, moveNode])
const makeReusable = useDocumentStore((s) => s.makeReusable)
const detachComponent = useDocumentStore((s) => s.detachComponent)
const handleContextAction = useCallback(
(action: string) => {
if (!contextMenu) return
@ -239,6 +271,12 @@ export default function LayerPanel() {
case 'hide':
toggleVisibility(nodeId)
break
case 'make-component':
makeReusable(nodeId)
break
case 'detach-component':
detachComponent(nodeId)
break
}
setContextMenu(null)
},
@ -251,6 +289,8 @@ export default function LayerPanel() {
toggleLock,
toggleVisibility,
setSelection,
makeReusable,
detachComponent,
],
)
@ -287,20 +327,34 @@ export default function LayerPanel() {
dragOverId,
dropPosition,
collapsedIds,
children,
)
)}
</div>
{contextMenu && (
<LayerContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canGroup={selectedIds.length >= 2}
onAction={handleContextAction}
onClose={() => setContextMenu(null)}
/>
)}
{contextMenu && (() => {
const contextNode = getNodeById(contextMenu.nodeId)
const isContainer = contextNode
? contextNode.type === 'frame' || contextNode.type === 'group' || contextNode.type === 'rectangle'
: false
const nodeIsReusable = contextNode
? 'reusable' in contextNode && contextNode.reusable === true
: false
const nodeIsInstance = contextNode?.type === 'ref'
return (
<LayerContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canGroup={selectedIds.length >= 2}
canCreateComponent={isContainer && !nodeIsReusable}
isReusable={nodeIsReusable}
isInstance={nodeIsInstance}
onAction={handleContextAction}
onClose={() => setContextMenu(null)}
/>
)
})()}
</div>
)
}

View file

@ -1,7 +1,11 @@
import { useState, useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { Separator } from '@/components/ui/separator'
import type { PenNode, ContainerProps } from '@/types/pen'
import type { PenNode, ContainerProps, RefNode } from '@/types/pen'
import { Component, Diamond, ArrowUpRight, Unlink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
import SizeSection from './size-section'
import LayoutSection from './layout-section'
import FillSection from './fill-section'
@ -10,51 +14,245 @@ import AppearanceSection from './appearance-section'
import TextSection from './text-section'
import EffectsSection from './effects-section'
/** Properties stored directly on the RefNode (instance-level), not as overrides. */
const INSTANCE_DIRECT_PROPS = new Set([
'x', 'y', 'width', 'height', 'name', 'visible', 'locked', 'rotation', 'opacity', 'flipX', 'flipY', 'enabled', 'theme',
])
export default function PropertyPanel() {
const activeId = useCanvasStore((s) => s.selection.activeId)
const setSelection = useCanvasStore((s) => s.setSelection)
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const children = useDocumentStore((s) => s.document.children)
const getNodeById = useDocumentStore((s) => s.getNodeById)
const updateNode = useDocumentStore((s) => s.updateNode)
const makeReusable = useDocumentStore((s) => s.makeReusable)
const detachComponent = useDocumentStore((s) => s.detachComponent)
// Subscribe to `children` so we re-render when nodes change
void children
const node = activeId ? getNodeById(activeId) : undefined
const handleUpdate = (updates: Partial<PenNode>) => {
if (activeId) {
updateNode(activeId, updates)
}
}
if (!node) {
return null
}
const hasLayout =
node.type === 'frame' || node.type === 'group' || node.type === 'rectangle'
const hasFill =
node.type !== 'line' && node.type !== 'ref'
const hasStroke = node.type !== 'ref'
const nodeIsReusable = 'reusable' in node && node.reusable === true
const nodeIsInstance = node.type === 'ref'
// For RefNodes, resolve the referenced component to get visual properties.
// The display node merges: component base → instance overrides → RefNode position/meta.
let displayNode = node
if (nodeIsInstance) {
const refNode = node as RefNode
const component = getNodeById(refNode.ref)
if (component) {
const topOverrides = refNode.descendants?.[refNode.ref] ?? {}
const merged: Record<string, unknown> = { ...component, ...topOverrides }
// Apply RefNode's own explicitly defined properties
for (const [key, val] of Object.entries(node)) {
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
if (val !== undefined) {
merged[key] = val
}
}
// Use component's type (frame/rect/etc.) not 'ref'
merged.type = component.type
if (!merged.name) merged.name = component.name
displayNode = merged as unknown as PenNode
}
}
const handleUpdate = (updates: Partial<PenNode>) => {
if (!activeId) return
if (nodeIsInstance && node.type === 'ref') {
const refNode = node as RefNode
const refNodeUpdate: Record<string, unknown> = {}
const overrideProps: Record<string, unknown> = {}
for (const [key, value] of Object.entries(updates)) {
if (INSTANCE_DIRECT_PROPS.has(key)) {
refNodeUpdate[key] = value
} else {
overrideProps[key] = value
}
}
// Store visual properties as overrides in descendants[ref]
if (Object.keys(overrideProps).length > 0) {
const currentDescendants = refNode.descendants ?? {}
const existing = currentDescendants[refNode.ref] ?? {}
refNodeUpdate.descendants = {
...currentDescendants,
[refNode.ref]: { ...existing, ...overrideProps },
}
}
if (Object.keys(refNodeUpdate).length > 0) {
updateNode(activeId, refNodeUpdate as Partial<PenNode>)
}
} else {
updateNode(activeId, updates)
}
}
const handleGoToComponent = () => {
if (!nodeIsInstance || node.type !== 'ref') return
const refId = (node as RefNode).ref
setSelection([refId], refId)
if (fabricCanvas) {
const objects = fabricCanvas.getObjects() as FabricObjectWithPenId[]
const target = objects.find((o) => (o as FabricObjectWithPenId).penNodeId === refId)
if (target) {
fabricCanvas.setActiveObject(target)
fabricCanvas.requestRenderAll()
}
}
}
const isContainer =
displayNode.type === 'frame' || displayNode.type === 'group' || displayNode.type === 'rectangle'
const hasLayout = isContainer
const hasFill = displayNode.type !== 'line'
const hasStroke = true
const hasCornerRadius =
node.type === 'rectangle' || node.type === 'frame'
const hasEffects = node.type !== 'ref'
const isText = node.type === 'text'
displayNode.type === 'rectangle' || displayNode.type === 'frame'
const hasEffects = true
const isText = displayNode.type === 'text'
const [isEditingName, setIsEditingName] = useState(false)
const [editName, setEditName] = useState('')
// Reset editing state when selection changes
useEffect(() => {
setIsEditingName(false)
}, [activeId])
const handleNameClick = () => {
setEditName(node.name ?? node.type)
setIsEditingName(true)
}
const handleNameBlur = () => {
setIsEditingName(false)
const trimmed = editName.trim()
if (trimmed && trimmed !== (node.name ?? node.type)) {
updateNode(activeId!, { name: trimmed } as Partial<PenNode>)
}
}
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
if (e.key === 'Escape') {
setIsEditingName(false)
setEditName(node.name ?? node.type)
}
}
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-foreground">
{node.name ?? node.type}
</span>
{/* Header */}
<div className="h-8 flex items-center px-2 border-b border-border gap-1">
{(nodeIsReusable || nodeIsInstance) && (
<Diamond size={12} className={`shrink-0 ${nodeIsReusable ? 'text-purple-400' : 'text-[#9281f7]'}`} />
)}
{isEditingName ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
className={`text-[11px] font-medium flex-1 min-w-0 bg-secondary rounded px-1.5 py-0.5 border border-ring focus:outline-none ${
nodeIsReusable
? 'text-purple-400'
: nodeIsInstance
? 'text-[#9281f7]'
: 'text-foreground'
}`}
autoFocus
/>
) : (
<span
className={`text-[11px] font-medium flex-1 truncate cursor-text ${
nodeIsReusable
? 'text-purple-400 border border-purple-400/50 rounded px-1.5 py-0.5'
: nodeIsInstance
? 'text-[#9281f7] border border-dashed border-[#9281f7]/50 rounded px-1.5 py-0.5'
: 'text-foreground px-1'
}`}
onClick={handleNameClick}
>
{node.name ?? node.type}
</span>
)}
{nodeIsInstance && (
<>
<button
type="button"
title="Go to component"
className="p-1 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground shrink-0"
onClick={handleGoToComponent}
>
<ArrowUpRight size={12} />
</button>
<button
type="button"
title="Detach instance"
className="p-1 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground shrink-0"
onClick={() => activeId && detachComponent(activeId)}
>
<Unlink size={12} />
</button>
</>
)}
</div>
<div className="flex-1 overflow-y-auto">
{(isContainer || nodeIsInstance) && (
<div className="px-3 py-2">
{nodeIsReusable ? (
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-purple-400 border-purple-500/30 hover:bg-purple-500/10"
onClick={() => activeId && detachComponent(activeId)}
>
<Unlink size={12} />
Detach Component
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs gap-1.5"
onClick={() => {
if (!activeId) return
if (nodeIsInstance) {
const newId = detachComponent(activeId)
if (newId) {
makeReusable(newId)
setSelection([newId], newId)
}
return
}
makeReusable(activeId)
}}
>
<Component size={12} />
Create Component
</Button>
)}
</div>
)}
<div className="px-3 py-2">
<SizeSection
node={node}
node={displayNode}
onUpdate={handleUpdate}
hasCornerRadius={hasCornerRadius}
cornerRadius={
'cornerRadius' in node ? node.cornerRadius : undefined
'cornerRadius' in displayNode ? displayNode.cornerRadius : undefined
}
/>
</div>
@ -63,7 +261,7 @@ export default function PropertyPanel() {
<>
<Separator />
<div className="px-3 py-2">
<LayoutSection node={node as PenNode & ContainerProps} onUpdate={handleUpdate} />
<LayoutSection node={displayNode as PenNode & ContainerProps} onUpdate={handleUpdate} />
</div>
</>
)}
@ -74,7 +272,7 @@ export default function PropertyPanel() {
<>
<div className="px-3 py-2">
<FillSection
fills={'fill' in node ? node.fill : undefined}
fills={'fill' in displayNode ? displayNode.fill : undefined}
onUpdate={handleUpdate}
/>
</div>
@ -86,7 +284,7 @@ export default function PropertyPanel() {
<>
<div className="px-3 py-2">
<StrokeSection
stroke={'stroke' in node ? node.stroke : undefined}
stroke={'stroke' in displayNode ? displayNode.stroke : undefined}
onUpdate={handleUpdate}
/>
</div>
@ -95,7 +293,7 @@ export default function PropertyPanel() {
)}
<div className="px-3 py-2">
<AppearanceSection node={node} onUpdate={handleUpdate} />
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
</div>
{hasEffects && (
@ -103,18 +301,18 @@ export default function PropertyPanel() {
<Separator />
<div className="px-3 py-2">
<EffectsSection
effects={'effects' in node ? node.effects : undefined}
effects={'effects' in displayNode ? displayNode.effects : undefined}
onUpdate={handleUpdate}
/>
</div>
</>
)}
{isText && node.type === 'text' && (
{isText && displayNode.type === 'text' && (
<>
<Separator />
<div className="px-3 py-2">
<TextSection node={node} onUpdate={handleUpdate} />
<TextSection node={displayNode} onUpdate={handleUpdate} />
</div>
</>
)}