refactor: rename PascalCase files to kebab-case

Rename all component files to kebab-case per project convention
(e.g. EditorLayout.tsx → editor-layout.tsx). Remove obsolete
PascalCase originals that were replaced by kebab-case versions.
This commit is contained in:
Kayshen-X 2026-02-18 21:50:03 +08:00
parent 23aa080979
commit 2dbfab49d3
19 changed files with 135 additions and 1118 deletions

View file

@ -1,26 +0,0 @@
import { useRef } from 'react'
import { useFabricCanvas } from './use-fabric-canvas'
import { useCanvasEvents } from './use-canvas-events'
import { useCanvasViewport } from './use-canvas-viewport'
import { useCanvasSelection } from './use-canvas-selection'
import { useCanvasSync } from './use-canvas-sync'
export default function FabricCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useFabricCanvas(canvasRef, containerRef)
useCanvasEvents()
useCanvasViewport()
useCanvasSelection()
useCanvasSync()
return (
<div
ref={containerRef}
className="flex-1 relative overflow-hidden bg-neutral-100"
>
<canvas ref={canvasRef} />
</div>
)
}

View file

@ -2,20 +2,22 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { Home, Menu, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function Header() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<header className="p-4 flex items-center bg-gray-800 text-white shadow-lg">
<button
<header className="p-4 flex items-center bg-card text-foreground border-b border-border">
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(true)}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Open menu"
>
<Menu size={24} />
</button>
<Menu size={20} />
</Button>
<h1 className="ml-4 text-xl font-semibold">
<Link to="/">
<img
@ -28,38 +30,35 @@ export default function Header() {
</header>
<aside
className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
className={`fixed top-0 left-0 h-full w-80 bg-background text-foreground shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-xl font-bold">Navigation</h2>
<button
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Close menu"
>
<X size={24} />
</button>
<X size={20} />
</Button>
</div>
<nav className="flex-1 p-4 overflow-y-auto">
<Link
to="/"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent transition-colors mb-2"
activeProps={{
className:
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
'flex items-center gap-3 p-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors mb-2',
}}
>
<Home size={20} />
<span className="font-medium">Home</span>
</Link>
{/* Demo Links Start */}
{/* Demo Links End */}
</nav>
</aside>
</>

View file

@ -1,27 +0,0 @@
import { lazy, Suspense } from 'react'
import Toolbar from './Toolbar'
import LayerPanel from '@/components/panels/LayerPanel'
import PropertyPanel from '@/components/panels/PropertyPanel'
const FabricCanvas = lazy(() => import('@/canvas/FabricCanvas'))
export default function EditorLayout() {
return (
<div className="h-screen flex flex-col bg-gray-900">
<Toolbar />
<div className="flex-1 flex overflow-hidden">
<LayerPanel />
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center bg-neutral-100 text-gray-400">
Loading canvas...
</div>
}
>
<FabricCanvas />
</Suspense>
<PropertyPanel />
</div>
</div>
)
}

View file

@ -1,37 +0,0 @@
import type { ReactNode } from 'react'
import type { ToolType } from '@/types/canvas'
import { useCanvasStore } from '@/stores/canvas-store'
interface ToolButtonProps {
tool: ToolType
icon: ReactNode
label: string
shortcut?: string
}
export default function ToolButton({
tool,
icon,
label,
shortcut,
}: ToolButtonProps) {
const activeTool = useCanvasStore((s) => s.activeTool)
const setActiveTool = useCanvasStore((s) => s.setActiveTool)
const isActive = activeTool === tool
return (
<button
type="button"
onClick={() => setActiveTool(tool)}
title={shortcut ? `${label} (${shortcut})` : label}
aria-label={label}
className={`p-1.5 rounded transition-colors ${
isActive
? 'bg-blue-500 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white'
}`}
>
{icon}
</button>
)
}

View file

@ -6,121 +6,138 @@ import {
Type,
Frame,
Hand,
ZoomIn,
ZoomOut,
Undo2,
Redo2,
} from 'lucide-react'
import ToolButton from './ToolButton'
import ToolButton from './tool-button'
import { useCanvasStore } from '@/stores/canvas-store'
import { MIN_ZOOM, MAX_ZOOM } from '@/canvas/canvas-constants'
import { useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
export default function Toolbar() {
const zoom = useCanvasStore((s) => s.viewport.zoom)
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const canUndo = useHistoryStore((s) => s.undoStack.length > 0)
const canRedo = useHistoryStore((s) => s.redoStack.length > 0)
const handleZoomIn = () => {
if (!fabricCanvas) return
const newZoom = Math.min(MAX_ZOOM, zoom * 1.25)
const center = fabricCanvas.getCenterPoint()
fabricCanvas.zoomToPoint(center, newZoom)
useCanvasStore.getState().setZoom(newZoom)
fabricCanvas.requestRenderAll()
const handleUndo = () => {
const currentDoc = useDocumentStore.getState().document
const prev = useHistoryStore.getState().undo(currentDoc)
if (prev) {
useDocumentStore.getState().applyHistoryState(prev)
}
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
}
const handleZoomOut = () => {
if (!fabricCanvas) return
const newZoom = Math.max(MIN_ZOOM, zoom / 1.25)
const center = fabricCanvas.getCenterPoint()
fabricCanvas.zoomToPoint(center, newZoom)
useCanvasStore.getState().setZoom(newZoom)
fabricCanvas.requestRenderAll()
}
const handleZoomReset = () => {
if (!fabricCanvas) return
const center = fabricCanvas.getCenterPoint()
fabricCanvas.zoomToPoint(center, 1)
useCanvasStore.getState().setZoom(1)
fabricCanvas.requestRenderAll()
const handleRedo = () => {
const currentDoc = useDocumentStore.getState().document
const next = useHistoryStore.getState().redo(currentDoc)
if (next) {
useDocumentStore.getState().applyHistoryState(next)
}
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
}
return (
<div className="h-10 bg-gray-800 border-b border-gray-700 flex items-center px-2 gap-1 shrink-0">
<div className="absolute top-2 left-2 z-10 w-10 bg-card border border-border rounded-xl flex flex-col items-center py-2 gap-1 shadow-lg">
{/* Drawing Tools */}
<div className="flex items-center gap-0.5 border-r border-gray-700 pr-2 mr-1">
<ToolButton
tool="select"
icon={<MousePointer2 size={16} />}
label="Select"
shortcut="V"
/>
<ToolButton
tool="frame"
icon={<Frame size={16} />}
label="Frame"
shortcut="F"
/>
<ToolButton
tool="rectangle"
icon={<Square size={16} />}
label="Rectangle"
shortcut="R"
/>
<ToolButton
tool="ellipse"
icon={<Circle size={16} />}
label="Ellipse"
shortcut="O"
/>
<ToolButton
tool="line"
icon={<Minus size={16} />}
label="Line"
shortcut="L"
/>
<ToolButton
tool="text"
icon={<Type size={16} />}
label="Text"
shortcut="T"
/>
<ToolButton
tool="hand"
icon={<Hand size={16} />}
label="Hand"
shortcut="H"
/>
</div>
<ToolButton
tool="select"
icon={<MousePointer2 size={20} />}
label="Select"
shortcut="V"
/>
<ToolButton
tool="rectangle"
icon={<Square size={20} />}
label="Rectangle"
shortcut="R"
/>
<ToolButton
tool="ellipse"
icon={<Circle size={20} />}
label="Ellipse"
shortcut="O"
/>
<ToolButton
tool="line"
icon={<Minus size={20} />}
label="Line"
shortcut="L"
/>
<ToolButton
tool="text"
icon={<Type size={20} />}
label="Text"
shortcut="T"
/>
<ToolButton
tool="frame"
icon={<Frame size={20} />}
label="Frame"
shortcut="F"
/>
<ToolButton
tool="hand"
icon={<Hand size={20} />}
label="Hand"
shortcut="H"
/>
{/* Spacer */}
<div className="flex-1" />
<Separator className="my-1 w-8" />
{/* Zoom Controls */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleZoomOut}
className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700 transition-colors"
title="Zoom Out"
>
<ZoomOut size={14} />
</button>
<button
type="button"
onClick={handleZoomReset}
className="text-xs text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-700 transition-colors tabular-nums min-w-[3.5rem] text-center"
title="Reset Zoom"
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
onClick={handleZoomIn}
className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700 transition-colors"
title="Zoom In"
>
<ZoomIn size={14} />
</button>
</div>
{/* Undo / Redo */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 size={18} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
Undo
<kbd className="ml-1.5 inline-flex h-4 items-center rounded border border-border/50 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
{'\u2318'}Z
</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 size={18} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
Redo
<kbd className="ml-1.5 inline-flex h-4 items-center rounded border border-border/50 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
{'\u2318\u21e7'}Z
</kbd>
</TooltipContent>
</Tooltip>
</div>
)
}

View file

@ -1,30 +0,0 @@
import SliderInput from '@/components/shared/SliderInput'
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-gray-300 uppercase tracking-wider">
Appearance
</h4>
<SliderInput
label="Opacity"
value={opacity}
onChange={(v) => onUpdate({ opacity: v / 100 })}
min={0}
max={100}
/>
</div>
)
}

View file

@ -1,35 +0,0 @@
import NumberInput from '@/components/shared/NumberInput'
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-gray-300 uppercase tracking-wider">
Corner Radius
</h4>
<NumberInput
value={value}
onChange={(v) =>
onUpdate({ cornerRadius: v } as Partial<PenNode>)
}
min={0}
max={999}
/>
</div>
)
}

View file

@ -1,32 +0,0 @@
import ColorPicker from '@/components/shared/ColorPicker'
import type { PenNode } from '@/types/pen'
import type { PenFill } from '@/types/styles'
interface FillSectionProps {
fills?: PenFill[]
onUpdate: (updates: Partial<PenNode>) => void
}
export default function FillSection({
fills,
onUpdate,
}: FillSectionProps) {
const currentColor =
fills && fills.length > 0 && fills[0].type === 'solid'
? fills[0].color
: '#d1d5db'
const handleColorChange = (color: string) => {
const newFills: PenFill[] = [{ type: 'solid', color }]
onUpdate({ fill: newFills } as Partial<PenNode>)
}
return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-300 uppercase tracking-wider">
Fill
</h4>
<ColorPicker value={currentColor} onChange={handleColorChange} />
</div>
)
}

View file

@ -1,128 +0,0 @@
import { useState } from 'react'
import {
Square,
Circle,
Type,
Minus,
Frame,
Eye,
EyeOff,
Lock,
Unlock,
FolderOpen,
Hexagon,
Spline,
Link,
} 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
onSelect: (id: string) => void
onRename: (id: string, name: string) => void
}
export default function LayerItem({
id,
name,
type,
depth,
selected,
onSelect,
onRename,
}: LayerItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState(name)
const [visible, setVisible] = useState(true)
const [locked, setLocked] = useState(false)
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)
}
}
return (
<div
className={`flex items-center h-7 px-1 gap-1 cursor-pointer rounded text-xs transition-colors ${
selected
? 'bg-blue-500/20 text-blue-300'
: 'text-gray-400 hover:bg-gray-700/50'
}`}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={() => onSelect(id)}
onDoubleClick={handleDoubleClick}
>
<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-gray-700 text-white text-xs px-1 py-0.5 rounded border border-blue-500 focus:outline-none"
autoFocus
/>
) : (
<span className="flex-1 truncate">{name}</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setVisible(!visible)
}}
className="p-0.5 opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity"
title={visible ? 'Hide' : 'Show'}
>
{visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setLocked(!locked)
}}
className="p-0.5 opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity"
title={locked ? 'Unlock' : 'Lock'}
>
{locked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
</div>
)
}

View file

@ -1,91 +0,0 @@
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 './LayerItem'
function renderLayerTree(
nodes: PenNode[],
depth: number,
selectedIds: string[],
onSelect: (id: string) => void,
onRename: (id: string, name: string) => void,
) {
// Render in reverse order so top items appear at top of panel
return [...nodes].reverse().map((node) => (
<div key={node.id} className="group">
<LayerItem
id={node.id}
name={node.name ?? node.type}
type={node.type}
depth={depth}
selected={selectedIds.includes(node.id)}
onSelect={onSelect}
onRename={onRename}
/>
{'children' in node &&
node.children &&
node.children.length > 0 &&
renderLayerTree(
node.children,
depth + 1,
selectedIds,
onSelect,
onRename,
)}
</div>
))
}
export default function LayerPanel() {
const children = useDocumentStore((s) => s.document.children)
const updateNode = useDocumentStore((s) => s.updateNode)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const setSelection = useCanvasStore((s) => s.setSelection)
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const handleSelect = (id: string) => {
setSelection([id], id)
// Also select the corresponding fabric object
if (fabricCanvas) {
const objects = fabricCanvas.getObjects()
const target = objects.find(
(o) => (o as FabricObjectWithPenId).penNodeId === id,
)
if (target) {
fabricCanvas.setActiveObject(target)
fabricCanvas.requestRenderAll()
}
}
}
const handleRename = (id: string, name: string) => {
updateNode(id, { name })
}
return (
<div className="w-56 bg-gray-800 border-r border-gray-700 flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-gray-700">
<span className="text-xs font-medium text-gray-300 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-gray-500 text-center mt-4 px-2">
No layers yet. Use the toolbar to draw shapes.
</p>
) : (
renderLayerTree(
children,
0,
selectedIds,
handleSelect,
handleRename,
)
)}
</div>
</div>
)
}

View file

@ -1,92 +0,0 @@
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
import SizeSection from './SizeSection'
import FillSection from './FillSection'
import StrokeSection from './StrokeSection'
import AppearanceSection from './AppearanceSection'
import CornerRadiusSection from './CornerRadiusSection'
import TextSection from './TextSection'
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-gray-800 border-l border-gray-700 flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-gray-700">
<span className="text-xs font-medium text-gray-300 uppercase tracking-wider">
Properties
</span>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-gray-500 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 isText = node.type === 'text'
return (
<div className="w-64 bg-gray-800 border-l border-gray-700 flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-gray-700">
<span className="text-xs font-medium text-gray-300 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} />
{isText && node.type === 'text' && (
<TextSection node={node} onUpdate={handleUpdate} />
)}
</div>
</div>
)
}

View file

@ -1,68 +0,0 @@
import NumberInput from '@/components/shared/NumberInput'
import type { PenNode } from '@/types/pen'
interface SizeSectionProps {
node: PenNode
onUpdate: (updates: Partial<PenNode>) => void
}
export default function SizeSection({ node, onUpdate }: SizeSectionProps) {
const x = node.x ?? 0
const y = node.y ?? 0
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-gray-300 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 })}
/>
<NumberInput
label="Y"
value={Math.round(y)}
onChange={(v) => onUpdate({ y: v })}
/>
{width !== undefined && (
<NumberInput
label="W"
value={Math.round(width)}
onChange={(v) =>
onUpdate({ width: v } as Partial<PenNode>)
}
min={1}
/>
)}
{height !== undefined && (
<NumberInput
label="H"
value={Math.round(height)}
onChange={(v) =>
onUpdate({ height: v } as Partial<PenNode>)
}
min={1}
/>
)}
</div>
<NumberInput
label="R"
value={Math.round(rotation)}
onChange={(v) => onUpdate({ rotation: v })}
suffix="°"
/>
</div>
)
}

View file

@ -1,61 +0,0 @@
import ColorPicker from '@/components/shared/ColorPicker'
import NumberInput from '@/components/shared/NumberInput'
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-gray-300 uppercase tracking-wider">
Stroke
</h4>
<ColorPicker value={strokeColor} onChange={handleColorChange} />
<NumberInput
label="W"
value={strokeWidth}
onChange={handleWidthChange}
min={0}
max={100}
step={1}
/>
</div>
)
}

View file

@ -1,86 +0,0 @@
import NumberInput from '@/components/shared/NumberInput'
import DropdownSelect from '@/components/shared/DropdownSelect'
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-gray-300 uppercase tracking-wider">
Typography
</h4>
<DropdownSelect
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(v) => onUpdate({ fontFamily: v } as Partial<PenNode>)}
/>
<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}
/>
<DropdownSelect
value={fontWeight}
options={WEIGHT_OPTIONS}
onChange={(v) =>
onUpdate({ fontWeight: Number(v) } as Partial<PenNode>)
}
/>
</div>
<DropdownSelect
label="Align"
value={textAlign}
options={ALIGN_OPTIONS}
onChange={(v) =>
onUpdate({
textAlign: v as TextNode['textAlign'],
} as Partial<PenNode>)
}
/>
</div>
)
}

View file

@ -1,66 +0,0 @@
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-gray-400">{label}</span>
)}
<div className="relative">
<input
type="color"
value={value.slice(0, 7)}
onChange={handleNativeChange}
className="w-6 h-6 rounded border border-gray-600 cursor-pointer bg-transparent p-0"
/>
</div>
<input
ref={inputRef}
type="text"
value={hexInput}
onChange={handleHexChange}
onBlur={handleBlur}
className="flex-1 bg-gray-700 text-white text-xs px-1.5 py-1 rounded border border-gray-600 focus:border-blue-500 focus:outline-none font-mono"
placeholder="#000000"
/>
</div>
)
}

View file

@ -1,34 +0,0 @@
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-gray-400">{label}</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 bg-gray-700 text-white text-xs px-1.5 py-1 rounded border border-gray-600 focus:border-blue-500 focus:outline-none cursor-pointer"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}

View file

@ -1,34 +0,0 @@
import type { ReactNode, ButtonHTMLAttributes } from 'react'
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode
label: string
active?: boolean
size?: 'sm' | 'md'
}
export default function IconButton({
icon,
label,
active = false,
size = 'md',
className = '',
...props
}: IconButtonProps) {
const sizeClass = size === 'sm' ? 'p-1' : 'p-1.5'
const activeClass = active
? 'bg-blue-500 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white'
return (
<button
type="button"
title={label}
aria-label={label}
className={`${sizeClass} rounded transition-colors ${activeClass} ${className}`}
{...props}
>
{icon}
</button>
)
}

View file

@ -1,111 +0,0 @@
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-gray-400 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-gray-700 text-white text-xs px-1.5 py-1 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
/>
{suffix && (
<span className="text-xs text-gray-500">{suffix}</span>
)}
</div>
)
}

View file

@ -1,41 +0,0 @@
interface SliderInputProps {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
label?: string
className?: string
}
export default function SliderInput({
value,
onChange,
min = 0,
max = 100,
step = 1,
label,
className = '',
}: SliderInputProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{label && (
<span className="text-xs text-gray-400 w-12 shrink-0">
{label}
</span>
)}
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 h-1 accent-blue-500 cursor-pointer"
/>
<span className="text-xs text-gray-300 w-8 text-right tabular-nums">
{Math.round(value)}
</span>
</div>
)
}