mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
01cc22412e
commit
a56577ea85
14 changed files with 1446 additions and 0 deletions
39
src/components/panels/appearance-section.tsx
Normal file
39
src/components/panels/appearance-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/panels/corner-radius-section.tsx
Normal file
35
src/components/panels/corner-radius-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
src/components/panels/effects-section.tsx
Normal file
113
src/components/panels/effects-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
src/components/panels/fill-section.tsx
Normal file
215
src/components/panels/fill-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/components/panels/layer-context-menu.tsx
Normal file
74
src/components/panels/layer-context-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
src/components/panels/layer-item.tsx
Normal file
168
src/components/panels/layer-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
src/components/panels/layer-panel.tsx
Normal file
228
src/components/panels/layer-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/components/panels/property-panel.tsx
Normal file
101
src/components/panels/property-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/components/panels/size-section.tsx
Normal file
73
src/components/panels/size-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/components/panels/stroke-section.tsx
Normal file
61
src/components/panels/stroke-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
src/components/panels/text-section.tsx
Normal file
128
src/components/panels/text-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
src/components/shared/color-picker.tsx
Normal file
66
src/components/shared/color-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/shared/dropdown-select.tsx
Normal file
34
src/components/shared/dropdown-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
src/components/shared/number-input.tsx
Normal file
111
src/components/shared/number-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue