mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
eaf66d976a
commit
f85bbe69dc
12 changed files with 330 additions and 232 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
15
src/components/shared/section-header.tsx
Normal file
15
src/components/shared/section-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue