feat(panels): add property inspector and layer management

Implement property panel with sections for size, fill, stroke, corner
radius, text, appearance, and effects. Add layer panel with drag-and-drop
reordering, visibility toggles, and right-click context menu. Shared
form components: color picker, number input, and dropdown select.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kayshen-X 2026-02-18 21:49:24 +08:00
parent 01cc22412e
commit a56577ea85
14 changed files with 1446 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import { Slider } from '@/components/ui/slider'
import type { PenNode } from '@/types/pen'
interface AppearanceSectionProps {
node: PenNode
onUpdate: (updates: Partial<PenNode>) => void
}
export default function AppearanceSection({
node,
onUpdate,
}: AppearanceSectionProps) {
const opacity =
typeof node.opacity === 'number' ? node.opacity * 100 : 100
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Appearance
</h4>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-12 shrink-0">
Opacity
</span>
<Slider
value={[opacity]}
onValueChange={([v]) => onUpdate({ opacity: v / 100 })}
min={0}
max={100}
step={1}
className="flex-1"
/>
<span className="text-xs text-foreground/70 w-8 text-right tabular-nums">
{Math.round(opacity)}
</span>
</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
import NumberInput from '@/components/shared/number-input'
import type { PenNode } from '@/types/pen'
interface CornerRadiusSectionProps {
cornerRadius?: number | [number, number, number, number]
onUpdate: (updates: Partial<PenNode>) => void
}
export default function CornerRadiusSection({
cornerRadius,
onUpdate,
}: CornerRadiusSectionProps) {
const value =
typeof cornerRadius === 'number'
? cornerRadius
: Array.isArray(cornerRadius)
? cornerRadius[0]
: 0
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Corner Radius
</h4>
<NumberInput
value={value}
onChange={(v) =>
onUpdate({ cornerRadius: v } as Partial<PenNode>)
}
min={0}
max={999}
/>
</div>
)
}

View file

@ -0,0 +1,113 @@
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import type { PenNode } from '@/types/pen'
import type { PenEffect, ShadowEffect } from '@/types/styles'
interface EffectsSectionProps {
effects?: PenEffect[]
onUpdate: (updates: Partial<PenNode>) => void
}
function findShadow(effects?: PenEffect[]): ShadowEffect | undefined {
return effects?.find((e): e is ShadowEffect => e.type === 'shadow')
}
export default function EffectsSection({
effects,
onUpdate,
}: EffectsSectionProps) {
const shadow = findShadow(effects)
const handleAddShadow = () => {
const current = effects ?? []
const newEffect: ShadowEffect = {
type: 'shadow',
offsetX: 4,
offsetY: 4,
blur: 8,
spread: 0,
color: 'rgba(0,0,0,0.25)',
}
onUpdate({
effects: [...current, newEffect],
} as Partial<PenNode>)
}
const handleRemoveShadow = () => {
const current = effects ?? []
onUpdate({
effects: current.filter((e) => e.type !== 'shadow'),
} as Partial<PenNode>)
}
const handleUpdateShadow = (updates: Partial<ShadowEffect>) => {
if (!shadow || !effects) return
const newEffects = effects.map((e) => {
if (e.type === 'shadow') return { ...e, ...updates }
return e
})
onUpdate({ effects: newEffects } as Partial<PenNode>)
}
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Effects
</h4>
{!shadow ? (
<button
type="button"
onClick={handleAddShadow}
className="text-xs text-primary hover:text-primary/80"
>
+ Add Shadow
</button>
) : (
<div className="space-y-2 bg-muted/30 rounded p-2">
<div className="flex items-center justify-between">
<span className="text-xs text-foreground">Shadow</span>
<button
type="button"
onClick={handleRemoveShadow}
className="text-xs text-muted-foreground hover:text-destructive"
>
Remove
</button>
</div>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="X"
value={shadow.offsetX}
onChange={(v) => handleUpdateShadow({ offsetX: v })}
/>
<NumberInput
label="Y"
value={shadow.offsetY}
onChange={(v) => handleUpdateShadow({ offsetY: v })}
/>
<NumberInput
label="Blur"
value={shadow.blur}
onChange={(v) => handleUpdateShadow({ blur: v })}
min={0}
/>
<NumberInput
label="Spread"
value={shadow.spread}
onChange={(v) => handleUpdateShadow({ spread: v })}
min={0}
/>
</div>
<ColorPicker
label="Color"
value={shadow.color}
onChange={(c) => handleUpdateShadow({ color: c })}
/>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,215 @@
import { useState } from 'react'
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import DropdownSelect from '@/components/shared/dropdown-select'
import type { PenNode } from '@/types/pen'
import type { PenFill, GradientStop } from '@/types/styles'
const FILL_TYPE_OPTIONS = [
{ value: 'solid', label: 'Solid' },
{ value: 'linear_gradient', label: 'Linear' },
{ value: 'radial_gradient', label: 'Radial' },
]
function defaultStops(): GradientStop[] {
return [
{ offset: 0, color: '#000000' },
{ offset: 1, color: '#ffffff' },
]
}
interface FillSectionProps {
fills?: PenFill[]
onUpdate: (updates: Partial<PenNode>) => void
}
export default function FillSection({
fills,
onUpdate,
}: FillSectionProps) {
const firstFill = fills?.[0]
const fillType = firstFill?.type ?? 'solid'
const [expanded, setExpanded] = useState(false)
const currentColor =
firstFill?.type === 'solid' ? firstFill.color : '#d1d5db'
const currentAngle =
firstFill?.type === 'linear_gradient' ? (firstFill.angle ?? 0) : 0
const currentStops: GradientStop[] =
firstFill &&
(firstFill.type === 'linear_gradient' ||
firstFill.type === 'radial_gradient')
? firstFill.stops
: defaultStops()
const handleTypeChange = (type: string) => {
let newFills: PenFill[]
if (type === 'solid') {
newFills = [{ type: 'solid', color: currentColor }]
} else if (type === 'linear_gradient') {
newFills = [
{
type: 'linear_gradient',
angle: currentAngle,
stops: currentStops,
},
]
} else {
newFills = [
{
type: 'radial_gradient',
cx: 0.5,
cy: 0.5,
radius: 0.5,
stops: currentStops,
},
]
}
onUpdate({ fill: newFills } as Partial<PenNode>)
}
const handleColorChange = (color: string) => {
onUpdate({ fill: [{ type: 'solid', color }] } as Partial<PenNode>)
}
const handleAngleChange = (angle: number) => {
if (firstFill?.type === 'linear_gradient') {
onUpdate({
fill: [{ ...firstFill, angle }],
} as Partial<PenNode>)
}
}
const handleStopColorChange = (index: number, color: string) => {
if (
!firstFill ||
(firstFill.type !== 'linear_gradient' &&
firstFill.type !== 'radial_gradient')
)
return
const newStops = [...firstFill.stops]
newStops[index] = { ...newStops[index], color }
onUpdate({
fill: [{ ...firstFill, stops: newStops }],
} as Partial<PenNode>)
}
const handleStopOffsetChange = (index: number, offset: number) => {
if (
!firstFill ||
(firstFill.type !== 'linear_gradient' &&
firstFill.type !== 'radial_gradient')
)
return
const newStops = [...firstFill.stops]
newStops[index] = { ...newStops[index], offset: offset / 100 }
onUpdate({
fill: [{ ...firstFill, stops: newStops }],
} as Partial<PenNode>)
}
const handleAddStop = () => {
if (
!firstFill ||
(firstFill.type !== 'linear_gradient' &&
firstFill.type !== 'radial_gradient')
)
return
const stops = [...firstFill.stops]
const lastOffset = stops[stops.length - 1]?.offset ?? 0.5
stops.push({ offset: Math.min(1, lastOffset + 0.1), color: '#888888' })
onUpdate({
fill: [{ ...firstFill, stops }],
} as Partial<PenNode>)
}
const handleRemoveStop = (index: number) => {
if (
!firstFill ||
(firstFill.type !== 'linear_gradient' &&
firstFill.type !== 'radial_gradient')
)
return
if (firstFill.stops.length <= 2) return
const stops = firstFill.stops.filter((_, i) => i !== index)
onUpdate({
fill: [{ ...firstFill, stops }],
} as Partial<PenNode>)
}
return (
<div className="space-y-2">
<button
type="button"
className="text-xs font-medium text-muted-foreground uppercase tracking-wider w-full text-left"
onClick={() => setExpanded(!expanded)}
>
Fill {expanded ? '-' : '+'}
</button>
<DropdownSelect
value={fillType}
options={FILL_TYPE_OPTIONS}
onChange={handleTypeChange}
/>
{fillType === 'solid' && (
<ColorPicker value={currentColor} onChange={handleColorChange} />
)}
{(fillType === 'linear_gradient' ||
fillType === 'radial_gradient') && (
<div className="space-y-2">
{fillType === 'linear_gradient' && (
<NumberInput
label="Angle"
value={currentAngle}
onChange={handleAngleChange}
min={0}
max={360}
suffix="deg"
/>
)}
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground">Color Stops</span>
{currentStops.map((stop, i) => (
<div key={i} className="flex items-center gap-1">
<ColorPicker
value={stop.color}
onChange={(c) => handleStopColorChange(i, c)}
/>
<NumberInput
value={Math.round(stop.offset * 100)}
onChange={(v) => handleStopOffsetChange(i, v)}
min={0}
max={100}
suffix="%"
className="w-16"
/>
{currentStops.length > 2 && (
<button
type="button"
onClick={() => handleRemoveStop(i)}
className="text-muted-foreground hover:text-red-400 text-xs px-1"
>
x
</button>
)}
</div>
))}
<button
type="button"
onClick={handleAddStop}
className="text-xs text-blue-400 hover:text-blue-300"
>
+ Add Stop
</button>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react'
import {
Trash2,
Copy,
Group,
Lock,
EyeOff,
} from 'lucide-react'
interface LayerContextMenuProps {
x: number
y: number
nodeId: string
canGroup: boolean
onAction: (action: string) => void
onClose: () => void
}
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: 'lock', label: 'Toggle Lock', icon: Lock },
{ action: 'hide', label: 'Toggle Visibility', icon: EyeOff },
]
export default function LayerContextMenu({
x,
y,
canGroup,
onAction,
onClose,
}: LayerContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [onClose])
return (
<div
ref={menuRef}
className="fixed z-50 bg-gray-800 border border-gray-600 rounded-md shadow-lg py-1 min-w-[160px]"
style={{ left: x, top: y }}
>
{MENU_ITEMS.filter(
(item) => !item.requireGroup || canGroup,
).map((item) => (
<button
key={item.action}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700 hover:text-white text-left"
onClick={() => onAction(item.action)}
>
<item.icon size={12} />
{item.label}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,168 @@
import { useState } from 'react'
import {
Square,
Circle,
Type,
Minus,
Frame,
Eye,
EyeOff,
Lock,
Unlock,
FolderOpen,
Hexagon,
Spline,
Link,
GripVertical,
} from 'lucide-react'
import type { PenNodeType } from '@/types/pen'
const TYPE_ICONS: Record<PenNodeType, typeof Square> = {
rectangle: Square,
ellipse: Circle,
text: Type,
line: Minus,
frame: Frame,
group: FolderOpen,
polygon: Hexagon,
path: Spline,
ref: Link,
}
interface LayerItemProps {
id: string
name: string
type: PenNodeType
depth: number
selected: boolean
visible: boolean
locked: boolean
onSelect: (id: string) => void
onRename: (id: string, name: string) => void
onToggleVisibility: (id: string) => void
onToggleLock: (id: string) => void
onContextMenu: (e: React.MouseEvent, id: string) => void
onDragStart: (id: string) => void
onDragOver: (id: string) => void
onDragEnd: () => void
}
export default function LayerItem({
id,
name,
type,
depth,
selected,
visible,
locked,
onSelect,
onRename,
onToggleVisibility,
onToggleLock,
onContextMenu,
onDragStart,
onDragOver,
onDragEnd,
}: LayerItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState(name)
const Icon = TYPE_ICONS[type] ?? Square
const handleDoubleClick = () => {
setEditName(name)
setIsEditing(true)
}
const handleRenameBlur = () => {
setIsEditing(false)
if (editName.trim() && editName !== name) {
onRename(id, editName.trim())
}
}
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleRenameBlur()
if (e.key === 'Escape') {
setIsEditing(false)
setEditName(name)
}
}
const handlePointerDown = (e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('[data-drag-handle]')) {
onDragStart(id)
}
}
return (
<div
className={`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'
} ${!visible ? 'opacity-40' : ''}`}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={() => onSelect(id)}
onDoubleClick={handleDoubleClick}
onContextMenu={(e) => onContextMenu(e, id)}
onPointerDown={handlePointerDown}
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>
<Icon size={12} className="shrink-0 opacity-60" />
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleRenameBlur}
onKeyDown={handleRenameKeyDown}
className="flex-1 bg-secondary text-foreground text-xs px-1 py-0.5 rounded border border-ring focus:outline-none"
autoFocus
/>
) : (
<span className="flex-1 truncate">{name}</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleVisibility(id)
}}
className={`p-0.5 transition-opacity ${
!visible
? 'opacity-100 text-yellow-400'
: 'opacity-0 group-hover:opacity-100'
}`}
title={visible ? 'Hide' : 'Show'}
>
{visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleLock(id)
}}
className={`p-0.5 transition-opacity ${
locked
? 'opacity-100 text-orange-400'
: 'opacity-0 group-hover:opacity-100'
}`}
title={locked ? 'Unlock' : 'Lock'}
>
{locked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
</div>
)
}

View file

@ -0,0 +1,228 @@
import { useState, useRef, useCallback } from 'react'
import { useDocumentStore } from '@/stores/document-store'
import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
import type { PenNode } from '@/types/pen'
import LayerItem from './layer-item'
import LayerContextMenu from './layer-context-menu'
interface DragState {
dragId: string | null
overId: string | null
}
function renderLayerTree(
nodes: PenNode[],
depth: number,
selectedIds: string[],
handlers: {
onSelect: (id: string) => void
onRename: (id: string, name: string) => void
onToggleVisibility: (id: string) => void
onToggleLock: (id: string) => void
onContextMenu: (e: React.MouseEvent, id: string) => void
onDragStart: (id: string) => void
onDragOver: (id: string) => void
onDragEnd: () => void
},
dragOverId: string | null,
) {
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}
/>
</div>
{'children' in node &&
node.children &&
node.children.length > 0 &&
renderLayerTree(
node.children,
depth + 1,
selectedIds,
handlers,
dragOverId,
)}
</div>
))
}
export default function LayerPanel() {
const children = useDocumentStore((s) => s.document.children)
const updateNode = useDocumentStore((s) => s.updateNode)
const removeNode = useDocumentStore((s) => s.removeNode)
const duplicateNode = useDocumentStore((s) => s.duplicateNode)
const toggleVisibility = useDocumentStore((s) => s.toggleVisibility)
const toggleLock = useDocumentStore((s) => s.toggleLock)
const groupNodes = useDocumentStore((s) => s.groupNodes)
const moveNode = useDocumentStore((s) => s.moveNode)
const getParentOf = useDocumentStore((s) => s.getParentOf)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const setSelection = useCanvasStore((s) => s.setSelection)
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
nodeId: string
} | null>(null)
const dragRef = useRef<DragState>({ dragId: null, overId: null })
const [dragOverId, setDragOverId] = useState<string | null>(null)
const handleSelect = useCallback(
(id: string) => {
setSelection([id], id)
if (fabricCanvas) {
const objects = fabricCanvas.getObjects()
const target = objects.find(
(o) => (o as FabricObjectWithPenId).penNodeId === id,
)
if (target) {
fabricCanvas.setActiveObject(target)
fabricCanvas.requestRenderAll()
}
}
},
[fabricCanvas, setSelection],
)
const handleRename = useCallback(
(id: string, name: string) => {
updateNode(id, { name })
},
[updateNode],
)
const handleContextMenu = useCallback(
(e: React.MouseEvent, id: string) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: id })
handleSelect(id)
},
[handleSelect],
)
const handleDragStart = useCallback((id: string) => {
dragRef.current.dragId = id
}, [])
const handleDragOver = useCallback((id: string) => {
if (dragRef.current.dragId && dragRef.current.dragId !== id) {
dragRef.current.overId = id
setDragOverId(id)
}
}, [])
const handleDragEnd = useCallback(() => {
const { dragId, overId } = dragRef.current
if (dragId && overId && dragId !== overId) {
const parent = getParentOf(overId)
const parentId = parent ? parent.id : null
const siblings = parent
? ('children' in parent ? parent.children ?? [] : [])
: children
const targetIdx = siblings.findIndex((n) => n.id === overId)
if (targetIdx !== -1) {
moveNode(dragId, parentId, targetIdx)
}
}
dragRef.current = { dragId: null, overId: null }
setDragOverId(null)
}, [children, getParentOf, moveNode])
const handleContextAction = useCallback(
(action: string) => {
if (!contextMenu) return
const { nodeId } = contextMenu
switch (action) {
case 'delete':
removeNode(nodeId)
break
case 'duplicate':
duplicateNode(nodeId)
break
case 'group':
if (selectedIds.length >= 2) {
const newGroupId = groupNodes(selectedIds)
if (newGroupId) {
setSelection([newGroupId], newGroupId)
}
}
break
case 'lock':
toggleLock(nodeId)
break
case 'hide':
toggleVisibility(nodeId)
break
}
setContextMenu(null)
},
[
contextMenu,
selectedIds,
removeNode,
duplicateNode,
groupNodes,
toggleLock,
toggleVisibility,
setSelection,
],
)
const handlers = {
onSelect: handleSelect,
onRename: handleRename,
onToggleVisibility: toggleVisibility,
onToggleLock: toggleLock,
onContextMenu: handleContextMenu,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
}
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 uppercase tracking-wider">
Layers
</span>
</div>
<div className="flex-1 overflow-y-auto py-1 px-1">
{children.length === 0 ? (
<p className="text-xs text-muted-foreground text-center mt-4 px-2">
No layers yet. Use the toolbar to draw shapes.
</p>
) : (
renderLayerTree(children, 0, selectedIds, handlers, dragOverId)
)}
</div>
{contextMenu && (
<LayerContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canGroup={selectedIds.length >= 2}
onAction={handleContextAction}
onClose={() => setContextMenu(null)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,101 @@
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
import SizeSection from './size-section'
import FillSection from './fill-section'
import StrokeSection from './stroke-section'
import AppearanceSection from './appearance-section'
import CornerRadiusSection from './corner-radius-section'
import TextSection from './text-section'
import EffectsSection from './effects-section'
export default function PropertyPanel() {
const activeId = useCanvasStore((s) => s.selection.activeId)
const children = useDocumentStore((s) => s.document.children)
const getNodeById = useDocumentStore((s) => s.getNodeById)
const updateNode = useDocumentStore((s) => s.updateNode)
// 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 (
<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-xs font-medium text-muted-foreground uppercase tracking-wider">
Properties
</span>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-muted-foreground px-4 text-center">
Select an element to view its properties.
</p>
</div>
</div>
)
}
const hasFill =
node.type !== 'line' && node.type !== 'ref'
const hasStroke = node.type !== 'ref'
const hasCornerRadius =
node.type === 'rectangle' || node.type === 'frame'
const hasEffects = node.type !== 'ref'
const isText = node.type === 'text'
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-xs font-medium text-muted-foreground uppercase tracking-wider">
{node.name ?? node.type}
</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<SizeSection node={node} onUpdate={handleUpdate} />
{hasFill && (
<FillSection
fills={'fill' in node ? node.fill : undefined}
onUpdate={handleUpdate}
/>
)}
{hasStroke && (
<StrokeSection
stroke={'stroke' in node ? node.stroke : undefined}
onUpdate={handleUpdate}
/>
)}
{hasCornerRadius && (
<CornerRadiusSection
cornerRadius={
'cornerRadius' in node ? node.cornerRadius : undefined
}
onUpdate={handleUpdate}
/>
)}
<AppearanceSection node={node} onUpdate={handleUpdate} />
{hasEffects && (
<EffectsSection
effects={'effects' in node ? node.effects : undefined}
onUpdate={handleUpdate}
/>
)}
{isText && node.type === 'text' && (
<TextSection node={node} onUpdate={handleUpdate} />
)}
</div>
</div>
)
}

View file

@ -0,0 +1,73 @@
import NumberInput from '@/components/shared/number-input'
import type { PenNode } from '@/types/pen'
import { nodeRenderInfo } from '@/canvas/use-canvas-sync'
interface SizeSectionProps {
node: PenNode
onUpdate: (updates: Partial<PenNode>) => void
}
export default function SizeSection({ node, onUpdate }: SizeSectionProps) {
// Show absolute canvas position (document stores relative-to-parent)
const info = nodeRenderInfo.get(node.id)
const offsetX = info?.parentOffsetX ?? 0
const offsetY = info?.parentOffsetY ?? 0
const x = (node.x ?? 0) + offsetX
const y = (node.y ?? 0) + offsetY
const rotation = node.rotation ?? 0
const width =
'width' in node && typeof node.width === 'number'
? node.width
: undefined
const height =
'height' in node && typeof node.height === 'number'
? node.height
: undefined
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Transform
</h4>
<div className="grid grid-cols-2 gap-1.5">
<NumberInput
label="X"
value={Math.round(x)}
onChange={(v) => onUpdate({ x: v - offsetX })}
/>
<NumberInput
label="Y"
value={Math.round(y)}
onChange={(v) => onUpdate({ y: v - offsetY })}
/>
{width !== undefined && (
<NumberInput
label="W"
value={Math.round(width)}
onChange={(v) =>
onUpdate({ width: v } as Partial<PenNode>)
}
min={1}
/>
)}
{height !== undefined && (
<NumberInput
label="H"
value={Math.round(height)}
onChange={(v) =>
onUpdate({ height: v } as Partial<PenNode>)
}
min={1}
/>
)}
</div>
<NumberInput
label="R"
value={Math.round(rotation)}
onChange={(v) => onUpdate({ rotation: v })}
suffix="°"
/>
</div>
)
}

View file

@ -0,0 +1,61 @@
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import type { PenNode } from '@/types/pen'
import type { PenStroke, PenFill } from '@/types/styles'
interface StrokeSectionProps {
stroke?: PenStroke
onUpdate: (updates: Partial<PenNode>) => void
}
export default function StrokeSection({
stroke,
onUpdate,
}: StrokeSectionProps) {
const strokeColor =
stroke?.fill && stroke.fill.length > 0 && stroke.fill[0].type === 'solid'
? stroke.fill[0].color
: '#374151'
const strokeWidth =
stroke && typeof stroke.thickness === 'number'
? stroke.thickness
: 0
const handleColorChange = (color: string) => {
const newFill: PenFill[] = [{ type: 'solid', color }]
const newStroke: PenStroke = {
...(stroke ?? { thickness: 1 }),
fill: newFill,
}
onUpdate({ stroke: newStroke } as Partial<PenNode>)
}
const handleWidthChange = (width: number) => {
const newStroke: PenStroke = {
...(stroke ?? {
thickness: 1,
fill: [{ type: 'solid', color: strokeColor }],
}),
thickness: width,
}
onUpdate({ stroke: newStroke } as Partial<PenNode>)
}
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Stroke
</h4>
<ColorPicker value={strokeColor} onChange={handleColorChange} />
<NumberInput
label="W"
value={strokeWidth}
onChange={handleWidthChange}
min={0}
max={100}
step={1}
/>
</div>
)
}

View file

@ -0,0 +1,128 @@
import NumberInput from '@/components/shared/number-input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { PenNode, TextNode } from '@/types/pen'
interface TextSectionProps {
node: TextNode
onUpdate: (updates: Partial<PenNode>) => void
}
const FONT_OPTIONS = [
{ value: 'Inter, sans-serif', label: 'Inter' },
{ value: 'Arial, sans-serif', label: 'Arial' },
{ value: 'Helvetica, sans-serif', label: 'Helvetica' },
{ value: 'Georgia, serif', label: 'Georgia' },
{ value: 'Times New Roman, serif', label: 'Times' },
{ value: 'Courier New, monospace', label: 'Courier' },
]
const WEIGHT_OPTIONS = [
{ value: '100', label: 'Thin' },
{ value: '300', label: 'Light' },
{ value: '400', label: 'Regular' },
{ value: '500', label: 'Medium' },
{ value: '600', label: 'Semibold' },
{ value: '700', label: 'Bold' },
{ value: '900', label: 'Black' },
]
const ALIGN_OPTIONS = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
{ value: 'justify', label: 'Justify' },
]
export default function TextSection({
node,
onUpdate,
}: TextSectionProps) {
const fontFamily = node.fontFamily ?? 'Inter, sans-serif'
const fontSize = node.fontSize ?? 16
const fontWeight = String(node.fontWeight ?? '400')
const textAlign = node.textAlign ?? 'left'
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Typography
</h4>
<div className="flex items-center gap-2">
{<span className="text-xs text-muted-foreground">Font</span>}
<Select
value={fontFamily}
onValueChange={(v) =>
onUpdate({ fontFamily: v } as Partial<PenNode>)
}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-1.5">
<NumberInput
label="Sz"
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
}
min={1}
max={999}
/>
<Select
value={fontWeight}
onValueChange={(v) =>
onUpdate({ fontWeight: Number(v) } as Partial<PenNode>)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEIGHT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Align</span>
<Select
value={textAlign}
onValueChange={(v) =>
onUpdate({
textAlign: v as TextNode['textAlign'],
} as Partial<PenNode>)
}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALIGN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View file

@ -0,0 +1,66 @@
import { useState, useRef, useEffect } from 'react'
interface ColorPickerProps {
value: string
onChange: (color: string) => void
label?: string
}
export default function ColorPicker({
value,
onChange,
label,
}: ColorPickerProps) {
const [hexInput, setHexInput] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setHexInput(value)
}, [value])
const handleHexChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
setHexInput(v)
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(v)) {
onChange(v)
}
}
const handleNativeChange = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
onChange(e.target.value)
setHexInput(e.target.value)
}
const handleBlur = () => {
if (!/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(hexInput)) {
setHexInput(value)
}
}
return (
<div className="flex items-center gap-2">
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
)}
<div className="relative">
<input
type="color"
value={value.slice(0, 7)}
onChange={handleNativeChange}
className="w-6 h-6 rounded-md border border-input cursor-pointer bg-transparent p-0"
/>
</div>
<input
ref={inputRef}
type="text"
value={hexInput}
onChange={handleHexChange}
onBlur={handleBlur}
className="flex-1 bg-secondary text-foreground text-xs px-1.5 py-1 rounded-md border border-input focus:border-ring focus:outline-none font-mono transition-colors"
placeholder="#000000"
/>
</div>
)
}

View file

@ -0,0 +1,34 @@
interface DropdownSelectProps {
value: string
options: { value: string; label: string }[]
onChange: (value: string) => void
label?: string
className?: string
}
export default function DropdownSelect({
value,
options,
onChange,
label,
className = '',
}: DropdownSelectProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 bg-secondary text-foreground text-xs px-1.5 py-1 rounded border border-border focus:border-ring focus:outline-none cursor-pointer"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}

View file

@ -0,0 +1,111 @@
import { useState, useRef, useCallback, useEffect } from 'react'
interface NumberInputProps {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
label?: string
suffix?: string
className?: string
}
export default function NumberInput({
value,
onChange,
min,
max,
step = 1,
label,
suffix,
className = '',
}: NumberInputProps) {
const [localValue, setLocalValue] = useState(String(value))
const [isDragging, setIsDragging] = useState(false)
const dragStartY = useRef(0)
const dragStartValue = useRef(0)
useEffect(() => {
if (!isDragging) {
setLocalValue(String(Math.round(value * 100) / 100))
}
}, [value, isDragging])
const clamp = useCallback(
(v: number) => {
let result = v
if (min !== undefined) result = Math.max(min, result)
if (max !== undefined) result = Math.min(max, result)
return result
},
[min, max],
)
const handleBlur = () => {
const parsed = parseFloat(localValue)
if (!isNaN(parsed)) {
onChange(clamp(parsed))
} else {
setLocalValue(String(value))
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleBlur()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
onChange(clamp(value + step))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
onChange(clamp(value - step))
}
}
const handleMouseDown = (e: React.MouseEvent) => {
if (e.target instanceof HTMLInputElement) return
setIsDragging(true)
dragStartY.current = e.clientY
dragStartValue.current = value
const handleMouseMove = (ev: MouseEvent) => {
const delta = dragStartY.current - ev.clientY
const newValue = clamp(dragStartValue.current + delta * step)
onChange(newValue)
}
const handleMouseUp = () => {
setIsDragging(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<div
className={`flex items-center gap-1 ${className}`}
onMouseDown={handleMouseDown}
>
{label && (
<span className="text-xs text-muted-foreground w-5 cursor-ew-resize select-none">
{label}
</span>
)}
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full bg-secondary text-foreground text-xs px-1.5 py-1 rounded-md border border-input focus:border-ring focus:outline-none transition-colors"
/>
{suffix && (
<span className="text-xs text-muted-foreground">{suffix}</span>
)}
</div>
)
}