feat(panels): enhance UI components with SectionHeader and improve layout

Introduce a new SectionHeader component for consistent section titles and action buttons across panels. Refactor existing sections (Appearance, Effects, Fill, Stroke, Text) to utilize SectionHeader, improving visual hierarchy and user experience. Update styles for better alignment and spacing in various input components, ensuring a cohesive design throughout the property panel.
This commit is contained in:
Kayshen-X 2026-02-18 23:34:23 +08:00
parent eaf66d976a
commit f85bbe69dc
12 changed files with 330 additions and 232 deletions

View file

@ -1,4 +1,5 @@
import { Slider } from '@/components/ui/slider'
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import type { PenNode } from '@/types/pen'
interface AppearanceSectionProps {
@ -14,26 +15,16 @@ export default function AppearanceSection({
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 className="space-y-1.5">
<SectionHeader title="Layer" />
<NumberInput
label="Opacity"
value={Math.round(opacity)}
onChange={(v) => onUpdate({ opacity: v / 100 })}
min={0}
max={100}
suffix="%"
/>
</div>
)
}

View file

@ -1,5 +1,8 @@
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import { Button } from '@/components/ui/button'
import { Plus, Minus } from 'lucide-react'
import type { PenNode } from '@/types/pen'
import type { PenEffect, ShadowEffect } from '@/types/styles'
@ -50,30 +53,36 @@ export default function EffectsSection({
}
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"
<div className="space-y-1.5">
<SectionHeader
title="Effects"
actions={
!shadow ? (
<Button
variant="ghost"
size="icon-sm"
onClick={handleAddShadow}
>
Remove
</button>
<Plus className="w-3.5 h-3.5" />
</Button>
) : undefined
}
/>
{shadow && (
<div className="space-y-1 bg-secondary/50 rounded p-1.5">
<div className="flex items-center justify-between h-5">
<span className="text-[11px] text-foreground">
Drop shadow
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={handleRemoveShadow}
className="h-5 w-5"
>
<Minus className="w-3 h-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-1">

View file

@ -2,6 +2,9 @@ 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 SectionHeader from '@/components/shared/section-header'
import { Button } from '@/components/ui/button'
import { Plus, X } from 'lucide-react'
import type { PenNode } from '@/types/pen'
import type { PenFill, GradientStop } from '@/types/styles'
@ -29,7 +32,7 @@ export default function FillSection({
}: FillSectionProps) {
const firstFill = fills?.[0]
const fillType = firstFill?.type ?? 'solid'
const [expanded, setExpanded] = useState(false)
const [showTypeSelector, setShowTypeSelector] = useState(false)
const currentColor =
firstFill?.type === 'solid' ? firstFill.color : '#d1d5db'
@ -140,28 +143,35 @@ export default function FillSection({
}
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}
<div className="space-y-1.5">
<SectionHeader
title="Fill"
actions={
<Button
variant="ghost"
size="icon-sm"
onClick={() => setShowTypeSelector(!showTypeSelector)}
>
<Plus className="w-3.5 h-3.5" />
</Button>
}
/>
{showTypeSelector && (
<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">
<div className="space-y-1.5">
{fillType === 'linear_gradient' && (
<NumberInput
label="Angle"
@ -169,12 +179,23 @@ export default function FillSection({
onChange={handleAngleChange}
min={0}
max={360}
suffix="deg"
suffix="°"
/>
)}
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground">Color Stops</span>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Stops
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={handleAddStop}
>
<Plus className="w-3 h-3" />
</Button>
</div>
{currentStops.map((stop, i) => (
<div key={i} className="flex items-center gap-1">
<ColorPicker
@ -190,23 +211,16 @@ export default function FillSection({
className="w-16"
/>
{currentStops.length > 2 && (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRemoveStop(i)}
className="text-muted-foreground hover:text-red-400 text-xs px-1"
>
x
</button>
<X className="w-3 h-3" />
</Button>
)}
</div>
))}
<button
type="button"
onClick={handleAddStop}
className="text-xs text-blue-400 hover:text-blue-300"
>
+ Add Stop
</button>
</div>
</div>
)}

View file

@ -199,7 +199,7 @@ export default function LayerPanel() {
return (
<div className="w-56 bg-card border-r border-border flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-border">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<span className="text-xs font-medium text-muted-foreground tracking-wider">
Layers
</span>
</div>

View file

@ -1,11 +1,11 @@
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { Separator } from '@/components/ui/separator'
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'
@ -29,12 +29,12 @@ export default function PropertyPanel() {
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 className="text-[11px] font-medium text-muted-foreground">
Design
</span>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-muted-foreground px-4 text-center">
<p className="text-[11px] text-muted-foreground px-4 text-center">
Select an element to view its properties.
</p>
</div>
@ -53,47 +53,71 @@ export default function PropertyPanel() {
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">
<span className="text-[11px] font-medium text-foreground">
{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}
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2">
<SizeSection
node={node}
onUpdate={handleUpdate}
/>
)}
{hasStroke && (
<StrokeSection
stroke={'stroke' in node ? node.stroke : undefined}
onUpdate={handleUpdate}
/>
)}
{hasCornerRadius && (
<CornerRadiusSection
hasCornerRadius={hasCornerRadius}
cornerRadius={
'cornerRadius' in node ? node.cornerRadius : undefined
}
onUpdate={handleUpdate}
/>
</div>
<Separator />
{hasFill && (
<>
<div className="px-3 py-2">
<FillSection
fills={'fill' in node ? node.fill : undefined}
onUpdate={handleUpdate}
/>
</div>
<Separator />
</>
)}
<AppearanceSection node={node} onUpdate={handleUpdate} />
{hasStroke && (
<>
<div className="px-3 py-2">
<StrokeSection
stroke={'stroke' in node ? node.stroke : undefined}
onUpdate={handleUpdate}
/>
</div>
<Separator />
</>
)}
<div className="px-3 py-2">
<AppearanceSection node={node} onUpdate={handleUpdate} />
</div>
{hasEffects && (
<EffectsSection
effects={'effects' in node ? node.effects : undefined}
onUpdate={handleUpdate}
/>
<>
<Separator />
<div className="px-3 py-2">
<EffectsSection
effects={'effects' in node ? node.effects : undefined}
onUpdate={handleUpdate}
/>
</div>
</>
)}
{isText && node.type === 'text' && (
<TextSection node={node} onUpdate={handleUpdate} />
<>
<Separator />
<div className="px-3 py-2">
<TextSection node={node} onUpdate={handleUpdate} />
</div>
</>
)}
</div>
</div>

View file

@ -1,14 +1,21 @@
import NumberInput from '@/components/shared/number-input'
import type { PenNode } from '@/types/pen'
import { nodeRenderInfo } from '@/canvas/use-canvas-sync'
import { RotateCw } from 'lucide-react'
interface SizeSectionProps {
node: PenNode
onUpdate: (updates: Partial<PenNode>) => void
hasCornerRadius?: boolean
cornerRadius?: number | [number, number, number, number]
}
export default function SizeSection({ node, onUpdate }: SizeSectionProps) {
// Show absolute canvas position (document stores relative-to-parent)
export default function SizeSection({
node,
onUpdate,
hasCornerRadius,
cornerRadius,
}: SizeSectionProps) {
const info = nodeRenderInfo.get(node.id)
const offsetX = info?.parentOffsetX ?? 0
const offsetY = info?.parentOffsetY ?? 0
@ -25,49 +32,61 @@ export default function SizeSection({ node, onUpdate }: SizeSectionProps) {
? node.height
: undefined
const cornerRadiusValue =
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">
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>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="R"
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}
/>
)}
<NumberInput
icon={<RotateCw />}
value={Math.round(rotation)}
onChange={(v) => onUpdate({ rotation: v })}
suffix="°"
/>
{hasCornerRadius && (
<NumberInput
label="R"
value={cornerRadiusValue}
onChange={(v) =>
onUpdate({ cornerRadius: v } as Partial<PenNode>)
}
min={0}
/>
)}
</div>
)
}

View file

@ -1,5 +1,6 @@
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import type { PenNode } from '@/types/pen'
import type { PenStroke, PenFill } from '@/types/styles'
@ -43,19 +44,19 @@ export default function StrokeSection({
}
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 className="space-y-1.5">
<SectionHeader title="Stroke" />
<div className="flex items-center gap-1">
<ColorPicker value={strokeColor} onChange={handleColorChange} />
<NumberInput
value={strokeWidth}
onChange={handleWidthChange}
min={0}
max={100}
step={1}
className="w-14"
/>
</div>
</div>
)
}

View file

@ -1,4 +1,5 @@
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import {
Select,
SelectContent,
@ -6,6 +7,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { AlignLeft, AlignCenter, AlignRight, AlignJustify } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PenNode, TextNode } from '@/types/pen'
interface TextSectionProps {
@ -33,10 +36,10 @@ const WEIGHT_OPTIONS = [
]
const ALIGN_OPTIONS = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
{ value: 'justify', label: 'Justify' },
{ value: 'left', icon: AlignLeft, label: 'Align left' },
{ value: 'center', icon: AlignCenter, label: 'Align center' },
{ value: 'right', icon: AlignRight, label: 'Align right' },
{ value: 'justify', icon: AlignJustify, label: 'Justify' },
]
export default function TextSection({
@ -49,33 +52,29 @@ export default function TextSection({
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">
<div className="space-y-1.5">
<SectionHeader title="Text" />
<Select
value={fontFamily}
onValueChange={(v) =>
onUpdate({ fontFamily: v } as Partial<PenNode>)
}
>
<SelectTrigger className="h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="Sz"
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
@ -89,7 +88,7 @@ export default function TextSection({
onUpdate({ fontWeight: Number(v) } as Partial<PenNode>)
}
>
<SelectTrigger>
<SelectTrigger className="h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -101,27 +100,28 @@ export default function TextSection({
</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 className="flex items-center gap-0.5">
{ALIGN_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
type="button"
aria-label={label}
onClick={() =>
onUpdate({
textAlign: value as TextNode['textAlign'],
} as Partial<PenNode>)
}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
textAlign === value
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
))}
</div>
</div>
)

View file

@ -1,15 +1,18 @@
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
interface ColorPickerProps {
value: string
onChange: (color: string) => void
label?: string
className?: string
}
export default function ColorPicker({
value,
onChange,
label,
className,
}: ColorPickerProps) {
const [hexInput, setHexInput] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
@ -40,27 +43,31 @@ export default function ColorPicker({
}
return (
<div className="flex items-center gap-2">
<div className={cn('flex items-center gap-1.5', className)}>
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-[10px] text-muted-foreground shrink-0">
{label}
</span>
)}
<div className="relative">
<div className="flex items-center h-6 bg-secondary rounded border border-transparent hover:border-input focus-within:border-ring transition-colors flex-1">
<div className="pl-1 shrink-0">
<input
type="color"
value={value.slice(0, 7)}
onChange={handleNativeChange}
className="w-4 h-4 rounded border border-input/50 cursor-pointer bg-transparent p-0"
/>
</div>
<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"
ref={inputRef}
type="text"
value={hexInput}
onChange={handleHexChange}
onBlur={handleBlur}
className="flex-1 bg-transparent text-foreground text-[11px] px-1.5 py-0.5 focus:outline-none font-mono tabular-nums min-w-0"
placeholder="#000000"
/>
</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

@ -1,3 +1,5 @@
import { cn } from '@/lib/utils'
interface DropdownSelectProps {
value: string
options: { value: string; label: string }[]
@ -14,14 +16,16 @@ export default function DropdownSelect({
className = '',
}: DropdownSelectProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={cn('flex items-center gap-1.5', className)}>
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-[10px] text-muted-foreground shrink-0">
{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"
className="flex-1 h-6 bg-secondary text-foreground text-[11px] px-1.5 rounded border border-transparent hover:border-input focus:border-ring focus:outline-none cursor-pointer transition-colors"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>

View file

@ -1,4 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { cn } from '@/lib/utils'
interface NumberInputProps {
value: number
@ -7,6 +8,7 @@ interface NumberInputProps {
max?: number
step?: number
label?: string
icon?: React.ReactNode
suffix?: string
className?: string
}
@ -18,6 +20,7 @@ export default function NumberInput({
max,
step = 1,
label,
icon,
suffix,
className = '',
}: NumberInputProps) {
@ -87,24 +90,35 @@ export default function NumberInput({
return (
<div
className={`flex items-center gap-1 ${className}`}
className={cn(
'flex items-center h-6 bg-secondary rounded border border-transparent',
'hover:border-input focus-within:border-ring transition-colors',
className,
)}
onMouseDown={handleMouseDown}
>
{label && (
<span className="text-xs text-muted-foreground w-5 cursor-ew-resize select-none">
<span className="text-[10px] text-muted-foreground pl-1.5 pr-0.5 cursor-ew-resize select-none shrink-0">
{label}
</span>
)}
{icon && (
<span className="pl-1 pr-0.5 text-muted-foreground cursor-ew-resize select-none shrink-0 [&_svg]:w-3 [&_svg]:h-3">
{icon}
</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"
className="w-full bg-transparent text-foreground text-[11px] px-1 py-0.5 focus:outline-none tabular-nums"
/>
{suffix && (
<span className="text-xs text-muted-foreground">{suffix}</span>
<span className="text-[10px] text-muted-foreground pr-1.5 shrink-0">
{suffix}
</span>
)}
</div>
)

View file

@ -0,0 +1,15 @@
interface SectionHeaderProps {
title: string
actions?: React.ReactNode
}
export default function SectionHeader({ title, actions }: SectionHeaderProps) {
return (
<div className="flex items-center justify-between h-7">
<span className="text-[11px] text-muted-foreground">{title}</span>
{actions && (
<div className="flex items-center gap-0.5">{actions}</div>
)}
</div>
)
}