mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
23aa080979
commit
2dbfab49d3
19 changed files with 135 additions and 1118 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue