mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Refactor favicon designer components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
140
src/components/FaviconDesigner/BrushSettingsPanel.tsx
Normal file
140
src/components/FaviconDesigner/BrushSettingsPanel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Drop, Gradient, PencilSimple, Sparkle } from '@phosphor-icons/react'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { BrushEffect } from './types'
|
||||
|
||||
type BrushSettingsPanelProps = {
|
||||
drawMode: 'draw' | 'erase'
|
||||
brushEffect: BrushEffect
|
||||
brushColor: string
|
||||
brushSize: number
|
||||
gradientColor: string
|
||||
glowIntensity: number
|
||||
onBrushEffectChange: (value: BrushEffect) => void
|
||||
onBrushColorChange: (value: string) => void
|
||||
onBrushSizeChange: (value: number) => void
|
||||
onGradientColorChange: (value: string) => void
|
||||
onGlowIntensityChange: (value: number) => void
|
||||
}
|
||||
|
||||
export const BrushSettingsPanel = ({
|
||||
drawMode,
|
||||
brushEffect,
|
||||
brushColor,
|
||||
brushSize,
|
||||
gradientColor,
|
||||
glowIntensity,
|
||||
onBrushEffectChange,
|
||||
onBrushColorChange,
|
||||
onBrushSizeChange,
|
||||
onGradientColorChange,
|
||||
onGlowIntensityChange,
|
||||
}: BrushSettingsPanelProps) => (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold">
|
||||
{drawMode === 'draw' ? copy.brush.settingsTitle : copy.brush.eraserSettingsTitle}
|
||||
</Label>
|
||||
|
||||
{drawMode === 'draw' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>{copy.brush.effectLabel}</Label>
|
||||
<Select value={brushEffect} onValueChange={(value) => onBrushEffectChange(value as BrushEffect)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">
|
||||
<div className="flex items-center gap-2">
|
||||
<PencilSimple size={16} />
|
||||
{copy.effects.solid}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gradient">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gradient size={16} />
|
||||
{copy.effects.gradient}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="spray">
|
||||
<div className="flex items-center gap-2">
|
||||
<Drop size={16} />
|
||||
{copy.effects.spray}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="glow">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkle size={16} />
|
||||
{copy.effects.glow}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{copy.brush.colorLabel}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={brushColor}
|
||||
onChange={(event) => onBrushColorChange(event.target.value)}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
value={brushColor}
|
||||
onChange={(event) => onBrushColorChange(event.target.value)}
|
||||
placeholder={copy.placeholders.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{brushEffect === 'gradient' && (
|
||||
<div>
|
||||
<Label>{copy.brush.gradientColorLabel}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={gradientColor}
|
||||
onChange={(event) => onGradientColorChange(event.target.value)}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
value={gradientColor}
|
||||
onChange={(event) => onGradientColorChange(event.target.value)}
|
||||
placeholder={copy.placeholders.gradient}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{brushEffect === 'glow' && (
|
||||
<div>
|
||||
<Label>{formatCopy(copy.brush.glowIntensity, { value: glowIntensity })}</Label>
|
||||
<Slider
|
||||
value={[glowIntensity]}
|
||||
onValueChange={([value]) => onGlowIntensityChange(value)}
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
{formatCopy(copy.brush.sizeLabel, {
|
||||
mode: drawMode === 'draw' ? copy.modes.draw : copy.modes.erase,
|
||||
size: brushSize,
|
||||
})}
|
||||
</Label>
|
||||
<Slider value={[brushSize]} onValueChange={([value]) => onBrushSizeChange(value)} min={1} max={20} step={1} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
28
src/components/FaviconDesigner/ColorInspector.tsx
Normal file
28
src/components/FaviconDesigner/ColorInspector.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { FaviconElement } from './types'
|
||||
|
||||
type ColorInspectorProps = {
|
||||
element: FaviconElement
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
}
|
||||
|
||||
export const ColorInspector = ({ element, onUpdateElement }: ColorInspectorProps) => (
|
||||
<div>
|
||||
<Label>{copy.inspector.color}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={element.color}
|
||||
onChange={(event) => onUpdateElement({ color: event.target.value })}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
value={element.color}
|
||||
onChange={(event) => onUpdateElement({ color: event.target.value })}
|
||||
placeholder={copy.placeholders.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
116
src/components/FaviconDesigner/DesignSettingsPanel.tsx
Normal file
116
src/components/FaviconDesigner/DesignSettingsPanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { PRESET_SIZES } from './constants'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { CanvasFilter, FaviconDesign } from './types'
|
||||
|
||||
type DesignSettingsPanelProps = {
|
||||
activeDesign: FaviconDesign
|
||||
activeDesignId: string
|
||||
designs: FaviconDesign[]
|
||||
onUpdateDesign: (updates: Partial<FaviconDesign>) => void
|
||||
onSelectDesign: (value: string) => void
|
||||
}
|
||||
|
||||
export const DesignSettingsPanel = ({
|
||||
activeDesign,
|
||||
activeDesignId,
|
||||
designs,
|
||||
onUpdateDesign,
|
||||
onSelectDesign,
|
||||
}: DesignSettingsPanelProps) => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>{copy.design.nameLabel}</Label>
|
||||
<Input
|
||||
value={activeDesign.name}
|
||||
onChange={(e) => onUpdateDesign({ name: e.target.value })}
|
||||
placeholder={copy.design.namePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{copy.design.selectLabel}</Label>
|
||||
<Select value={activeDesignId} onValueChange={onSelectDesign}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{designs.map((design) => (
|
||||
<SelectItem key={design.id} value={design.id}>
|
||||
{design.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{copy.design.sizeLabel}</Label>
|
||||
<Select value={String(activeDesign.size)} onValueChange={(value) => onUpdateDesign({ size: Number(value) })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESET_SIZES.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}x{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{copy.design.backgroundLabel}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={activeDesign.backgroundColor}
|
||||
onChange={(e) => onUpdateDesign({ backgroundColor: e.target.value })}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
value={activeDesign.backgroundColor}
|
||||
onChange={(e) => onUpdateDesign({ backgroundColor: e.target.value })}
|
||||
placeholder={copy.design.backgroundPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{copy.design.filterLabel}</Label>
|
||||
<Select
|
||||
value={activeDesign.filter || 'none'}
|
||||
onValueChange={(value) => onUpdateDesign({ filter: value as CanvasFilter })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(copy.filters) as Array<keyof typeof copy.filters>).map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{copy.filters[key]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeDesign.filter && activeDesign.filter !== 'none' && (
|
||||
<div>
|
||||
<Label>{formatCopy(copy.design.filterIntensity, { value: activeDesign.filterIntensity || 50 })}</Label>
|
||||
<Slider
|
||||
value={[activeDesign.filterIntensity || 50]}
|
||||
onValueChange={([value]) => onUpdateDesign({ filterIntensity: value })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
42
src/components/FaviconDesigner/ElementInspectorPanel.tsx
Normal file
42
src/components/FaviconDesigner/ElementInspectorPanel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Label } from '@/components/ui/label'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { ColorInspector } from './ColorInspector'
|
||||
import { FreehandInspector } from './FreehandInspector'
|
||||
import { ShapeInspector } from './ShapeInspector'
|
||||
import { TextEmojiInspector } from './TextEmojiInspector'
|
||||
import { TransformInspector } from './TransformInspector'
|
||||
import { FaviconDesign, FaviconElement } from './types'
|
||||
|
||||
type ElementInspectorPanelProps = {
|
||||
activeDesign: FaviconDesign
|
||||
selectedElement: FaviconElement
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
}
|
||||
|
||||
export const ElementInspectorPanel = ({
|
||||
activeDesign,
|
||||
selectedElement,
|
||||
onUpdateElement,
|
||||
}: ElementInspectorPanelProps) => (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold">{copy.inspector.title}</Label>
|
||||
|
||||
{selectedElement.type === 'freehand' && (
|
||||
<FreehandInspector element={selectedElement} onUpdateElement={onUpdateElement} />
|
||||
)}
|
||||
|
||||
{(selectedElement.type === 'text' || selectedElement.type === 'emoji') && (
|
||||
<TextEmojiInspector element={selectedElement} onUpdateElement={onUpdateElement} />
|
||||
)}
|
||||
|
||||
{selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && (
|
||||
<ShapeInspector element={selectedElement} activeDesign={activeDesign} onUpdateElement={onUpdateElement} />
|
||||
)}
|
||||
|
||||
{selectedElement.type !== 'freehand' && (
|
||||
<TransformInspector element={selectedElement} activeDesign={activeDesign} onUpdateElement={onUpdateElement} />
|
||||
)}
|
||||
|
||||
{selectedElement.type !== 'freehand' && <ColorInspector element={selectedElement} onUpdateElement={onUpdateElement} />}
|
||||
</div>
|
||||
)
|
||||
104
src/components/FaviconDesigner/ElementsPanel.tsx
Normal file
104
src/components/FaviconDesigner/ElementsPanel.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { PencilSimple, Trash } from '@phosphor-icons/react'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { ELEMENT_TYPES } from './constants'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { FaviconDesign, FaviconElement } from './types'
|
||||
|
||||
type ElementsPanelProps = {
|
||||
activeDesign: FaviconDesign
|
||||
drawMode: 'select' | 'draw' | 'erase'
|
||||
selectedElementId: string | null
|
||||
onAddElement: (type: FaviconElement['type']) => void
|
||||
onSelectElement: (id: string) => void
|
||||
onDeleteElement: (id: string) => void
|
||||
}
|
||||
|
||||
export const ElementsPanel = ({
|
||||
activeDesign,
|
||||
drawMode,
|
||||
selectedElementId,
|
||||
onAddElement,
|
||||
onSelectElement,
|
||||
onDeleteElement,
|
||||
}: ElementsPanelProps) => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-3 block">{copy.elements.addTitle}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{ELEMENT_TYPES.map(({ value, icon: Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddElement(value as FaviconElement['type'])}
|
||||
className="flex flex-col gap-1 h-auto py-2"
|
||||
disabled={drawMode !== 'select'}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-xs">
|
||||
{copy.elementTypes[value as keyof typeof copy.elementTypes]}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{drawMode !== 'select' && <p className="text-xs text-muted-foreground mt-2">{copy.elements.selectHint}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">{formatCopy(copy.elements.listTitle, { count: activeDesign.elements.length })}</Label>
|
||||
<ScrollArea className="h-40">
|
||||
<div className="space-y-2">
|
||||
{activeDesign.elements.map((element) => (
|
||||
<div
|
||||
key={element.id}
|
||||
className={`flex items-center justify-between p-2 rounded border cursor-pointer ${
|
||||
selectedElementId === element.id ? 'border-primary bg-primary/10' : 'border-border hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (drawMode === 'select') {
|
||||
onSelectElement(element.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{element.type === 'freehand' ? (
|
||||
<PencilSimple size={16} />
|
||||
) : (
|
||||
ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && (
|
||||
<span>
|
||||
{(() => {
|
||||
const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon
|
||||
return <Icon size={16} />
|
||||
})()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span className="text-sm capitalize">
|
||||
{copy.elementTypes[element.type as keyof typeof copy.elementTypes] || element.type}
|
||||
</span>
|
||||
{element.text && <span className="text-xs text-muted-foreground">"{element.text}"</span>}
|
||||
{element.emoji && <span className="text-xs">{element.emoji}</span>}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDeleteElement(element.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{activeDesign.elements.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{copy.elements.empty}</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
127
src/components/FaviconDesigner/FaviconDesignerCanvas.tsx
Normal file
127
src/components/FaviconDesigner/FaviconDesignerCanvas.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Download } from '@phosphor-icons/react'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { PRESET_SIZES } from './constants'
|
||||
import { formatCopy } from './formatCopy'
|
||||
|
||||
type FaviconDesignerCanvasProps = {
|
||||
activeSize: number
|
||||
brushEffect: string
|
||||
brushSize: number
|
||||
canvasRef: React.RefObject<HTMLCanvasElement>
|
||||
drawingCanvasRef: React.RefObject<HTMLCanvasElement>
|
||||
drawMode: 'select' | 'draw' | 'erase'
|
||||
onExport: (format: 'png' | 'ico' | 'svg', size?: number) => void
|
||||
onExportAll: () => void
|
||||
onMouseDown: (event: React.MouseEvent<HTMLCanvasElement>) => void
|
||||
onMouseMove: (event: React.MouseEvent<HTMLCanvasElement>) => void
|
||||
onMouseUp: () => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
export const FaviconDesignerCanvas = ({
|
||||
activeSize,
|
||||
brushEffect,
|
||||
brushSize,
|
||||
canvasRef,
|
||||
drawingCanvasRef,
|
||||
drawMode,
|
||||
onExport,
|
||||
onExportAll,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
}: FaviconDesignerCanvasProps) => (
|
||||
<div className="border-r border-border p-6 flex flex-col items-center justify-center bg-muted/20">
|
||||
<Card className="p-8 mb-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="border-2 border-border rounded-lg shadow-xl absolute top-0 left-0"
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
imageRendering: 'pixelated',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={drawingCanvasRef}
|
||||
className="border-2 border-border rounded-lg shadow-xl relative z-10"
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
imageRendering: 'pixelated',
|
||||
cursor: drawMode === 'draw' ? 'crosshair' : drawMode === 'erase' ? 'not-allowed' : 'default',
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
<Badge className="absolute -top-3 -right-3">
|
||||
{activeSize}x{activeSize}
|
||||
</Badge>
|
||||
{drawMode !== 'select' && (
|
||||
<Badge className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-accent">
|
||||
{drawMode === 'draw'
|
||||
? formatCopy(copy.canvas.brushBadge, {
|
||||
effect: copy.effects[brushEffect as keyof typeof copy.effects] || brushEffect,
|
||||
size: brushSize,
|
||||
})
|
||||
: formatCopy(copy.canvas.eraserBadge, { size: brushSize * 2 })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap justify-center">
|
||||
{PRESET_SIZES.map((size) => (
|
||||
<div
|
||||
key={size}
|
||||
className="flex flex-col items-center gap-1 p-2 rounded border border-border hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => onExport('png', size)}
|
||||
title={formatCopy(copy.canvas.exportPresetTitle, { size })}
|
||||
>
|
||||
<canvas
|
||||
width={size}
|
||||
height={size}
|
||||
ref={(canvas) => {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx || !canvasRef.current) return
|
||||
ctx.drawImage(canvasRef.current, 0, 0, size, size)
|
||||
}}
|
||||
className="border border-border rounded"
|
||||
style={{ width: `${size / 2}px`, height: `${size / 2}px` }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCopy(copy.canvas.presetLabel, { size })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => onExport('png')}>
|
||||
<Download size={16} className="mr-2" />
|
||||
{copy.export.png}
|
||||
</Button>
|
||||
<Button onClick={() => onExport('svg')} variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
{copy.export.svg}
|
||||
</Button>
|
||||
<Button onClick={onExportAll} variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
{copy.export.all}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
110
src/components/FaviconDesigner/FaviconDesignerSidebar.tsx
Normal file
110
src/components/FaviconDesigner/FaviconDesignerSidebar.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { BrushSettingsPanel } from './BrushSettingsPanel'
|
||||
import { DesignSettingsPanel } from './DesignSettingsPanel'
|
||||
import { ElementInspectorPanel } from './ElementInspectorPanel'
|
||||
import { ElementsPanel } from './ElementsPanel'
|
||||
import { BrushEffect, FaviconDesign, FaviconElement } from './types'
|
||||
|
||||
type FaviconDesignerSidebarProps = {
|
||||
activeDesign: FaviconDesign
|
||||
activeDesignId: string
|
||||
brushColor: string
|
||||
brushEffect: BrushEffect
|
||||
brushSize: number
|
||||
drawMode: 'select' | 'draw' | 'erase'
|
||||
glowIntensity: number
|
||||
gradientColor: string
|
||||
selectedElement: FaviconElement | undefined
|
||||
selectedElementId: string | null
|
||||
designs: FaviconDesign[]
|
||||
onAddElement: (type: FaviconElement['type']) => void
|
||||
onDeleteElement: (id: string) => void
|
||||
onSelectElement: (id: string) => void
|
||||
onSelectDesign: (value: string) => void
|
||||
onUpdateDesign: (updates: Partial<FaviconDesign>) => void
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
onBrushEffectChange: (value: BrushEffect) => void
|
||||
onBrushColorChange: (value: string) => void
|
||||
onBrushSizeChange: (value: number) => void
|
||||
onGradientColorChange: (value: string) => void
|
||||
onGlowIntensityChange: (value: number) => void
|
||||
}
|
||||
|
||||
export const FaviconDesignerSidebar = ({
|
||||
activeDesign,
|
||||
activeDesignId,
|
||||
brushColor,
|
||||
brushEffect,
|
||||
brushSize,
|
||||
drawMode,
|
||||
glowIntensity,
|
||||
gradientColor,
|
||||
selectedElement,
|
||||
selectedElementId,
|
||||
designs,
|
||||
onAddElement,
|
||||
onDeleteElement,
|
||||
onSelectElement,
|
||||
onSelectDesign,
|
||||
onUpdateDesign,
|
||||
onUpdateElement,
|
||||
onBrushEffectChange,
|
||||
onBrushColorChange,
|
||||
onBrushSizeChange,
|
||||
onGradientColorChange,
|
||||
onGlowIntensityChange,
|
||||
}: FaviconDesignerSidebarProps) => (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6 space-y-6">
|
||||
<DesignSettingsPanel
|
||||
activeDesign={activeDesign}
|
||||
activeDesignId={activeDesignId}
|
||||
designs={designs}
|
||||
onUpdateDesign={onUpdateDesign}
|
||||
onSelectDesign={onSelectDesign}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ElementsPanel
|
||||
activeDesign={activeDesign}
|
||||
drawMode={drawMode}
|
||||
selectedElementId={selectedElementId}
|
||||
onAddElement={onAddElement}
|
||||
onSelectElement={onSelectElement}
|
||||
onDeleteElement={onDeleteElement}
|
||||
/>
|
||||
|
||||
{drawMode !== 'select' && (
|
||||
<>
|
||||
<Separator />
|
||||
<BrushSettingsPanel
|
||||
drawMode={drawMode}
|
||||
brushEffect={brushEffect}
|
||||
brushColor={brushColor}
|
||||
brushSize={brushSize}
|
||||
gradientColor={gradientColor}
|
||||
glowIntensity={glowIntensity}
|
||||
onBrushEffectChange={onBrushEffectChange}
|
||||
onBrushColorChange={onBrushColorChange}
|
||||
onBrushSizeChange={onBrushSizeChange}
|
||||
onGradientColorChange={onGradientColorChange}
|
||||
onGlowIntensityChange={onGlowIntensityChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedElement && drawMode === 'select' && (
|
||||
<>
|
||||
<Separator />
|
||||
<ElementInspectorPanel
|
||||
activeDesign={activeDesign}
|
||||
selectedElement={selectedElement}
|
||||
onUpdateElement={onUpdateElement}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
57
src/components/FaviconDesigner/FaviconDesignerToolbar.tsx
Normal file
57
src/components/FaviconDesigner/FaviconDesignerToolbar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Copy, Eraser, PencilSimple, Plus, Trash } from '@phosphor-icons/react'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
|
||||
type FaviconDesignerToolbarProps = {
|
||||
drawMode: 'select' | 'draw' | 'erase'
|
||||
canDelete: boolean
|
||||
onNewDesign: () => void
|
||||
onDuplicateDesign: () => void
|
||||
onDeleteDesign: () => void
|
||||
onSelectMode: () => void
|
||||
onDrawMode: () => void
|
||||
onEraseMode: () => void
|
||||
}
|
||||
|
||||
export const FaviconDesignerToolbar = ({
|
||||
drawMode,
|
||||
canDelete,
|
||||
onNewDesign,
|
||||
onDuplicateDesign,
|
||||
onDeleteDesign,
|
||||
onSelectMode,
|
||||
onDrawMode,
|
||||
onEraseMode,
|
||||
}: FaviconDesignerToolbarProps) => (
|
||||
<div className="border-b border-border bg-card px-4 sm:px-6 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onNewDesign}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
{copy.toolbar.newDesign}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDuplicateDesign}>
|
||||
<Copy size={16} className="mr-2" />
|
||||
{copy.toolbar.duplicate}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDeleteDesign} disabled={!canDelete}>
|
||||
<Trash size={16} className="mr-2" />
|
||||
{copy.toolbar.delete}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant={drawMode === 'select' ? 'default' : 'outline'} size="sm" onClick={onSelectMode}>
|
||||
{copy.modes.select}
|
||||
</Button>
|
||||
<Button variant={drawMode === 'draw' ? 'default' : 'outline'} size="sm" onClick={onDrawMode}>
|
||||
<PencilSimple size={16} className="mr-2" />
|
||||
{copy.modes.draw}
|
||||
</Button>
|
||||
<Button variant={drawMode === 'erase' ? 'default' : 'outline'} size="sm" onClick={onEraseMode}>
|
||||
<Eraser size={16} className="mr-2" />
|
||||
{copy.modes.erase}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
115
src/components/FaviconDesigner/FreehandInspector.tsx
Normal file
115
src/components/FaviconDesigner/FreehandInspector.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Drop, Gradient, PencilSimple, Sparkle } from '@phosphor-icons/react'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { BrushEffect, FaviconElement } from './types'
|
||||
|
||||
type FreehandInspectorProps = {
|
||||
element: FaviconElement
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
}
|
||||
|
||||
export const FreehandInspector = ({ element, onUpdateElement }: FreehandInspectorProps) => (
|
||||
<>
|
||||
<div>
|
||||
<Label>{copy.brush.effectLabel}</Label>
|
||||
<Select
|
||||
value={element.brushEffect || 'solid'}
|
||||
onValueChange={(value) => onUpdateElement({ brushEffect: value as BrushEffect })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">
|
||||
<div className="flex items-center gap-2">
|
||||
<PencilSimple size={16} />
|
||||
{copy.effects.solid}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gradient">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gradient size={16} />
|
||||
{copy.effects.gradient}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="spray">
|
||||
<div className="flex items-center gap-2">
|
||||
<Drop size={16} />
|
||||
{copy.effects.spray}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="glow">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkle size={16} />
|
||||
{copy.effects.glow}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{copy.inspector.strokeColor}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={element.color}
|
||||
onChange={(event) => onUpdateElement({ color: event.target.value })}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
value={element.color}
|
||||
onChange={(event) => onUpdateElement({ color: event.target.value })}
|
||||
placeholder={copy.placeholders.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{element.brushEffect === 'gradient' && (
|
||||
<div>
|
||||
<Label>{copy.brush.gradientColorLabel}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={element.gradientColor || copy.placeholders.gradient}
|
||||
onChange={(event) => onUpdateElement({ gradientColor: event.target.value })}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
value={element.gradientColor || copy.placeholders.gradient}
|
||||
onChange={(event) => onUpdateElement({ gradientColor: event.target.value })}
|
||||
placeholder={copy.placeholders.gradient}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{element.brushEffect === 'glow' && (
|
||||
<div>
|
||||
<Label>{formatCopy(copy.brush.glowIntensity, { value: element.glowIntensity || 10 })}</Label>
|
||||
<Slider
|
||||
value={[element.glowIntensity || 10]}
|
||||
onValueChange={([value]) => onUpdateElement({ glowIntensity: value })}
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.strokeWidth, { value: element.strokeWidth || 3 })}</Label>
|
||||
<Slider
|
||||
value={[element.strokeWidth || 3]}
|
||||
onValueChange={([value]) => onUpdateElement({ strokeWidth: value })}
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
36
src/components/FaviconDesigner/ShapeInspector.tsx
Normal file
36
src/components/FaviconDesigner/ShapeInspector.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { FaviconDesign, FaviconElement } from './types'
|
||||
|
||||
type ShapeInspectorProps = {
|
||||
element: FaviconElement
|
||||
activeDesign: FaviconDesign
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
}
|
||||
|
||||
export const ShapeInspector = ({ element, activeDesign, onUpdateElement }: ShapeInspectorProps) => (
|
||||
<>
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.width, { value: element.width })}</Label>
|
||||
<Slider
|
||||
value={[element.width]}
|
||||
onValueChange={([value]) => onUpdateElement({ width: value })}
|
||||
min={10}
|
||||
max={activeDesign.size}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.height, { value: element.height })}</Label>
|
||||
<Slider
|
||||
value={[element.height]}
|
||||
onValueChange={([value]) => onUpdateElement({ height: value })}
|
||||
min={10}
|
||||
max={activeDesign.size}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
65
src/components/FaviconDesigner/TextEmojiInspector.tsx
Normal file
65
src/components/FaviconDesigner/TextEmojiInspector.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { FaviconElement } from './types'
|
||||
|
||||
type TextEmojiInspectorProps = {
|
||||
element: FaviconElement
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
}
|
||||
|
||||
export const TextEmojiInspector = ({ element, onUpdateElement }: TextEmojiInspectorProps) => (
|
||||
<>
|
||||
{element.type === 'text' && (
|
||||
<div>
|
||||
<Label>{copy.inspector.textLabel}</Label>
|
||||
<Input
|
||||
value={element.text || ''}
|
||||
onChange={(event) => onUpdateElement({ text: event.target.value })}
|
||||
placeholder={copy.inspector.textPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{element.type === 'emoji' && (
|
||||
<div>
|
||||
<Label>{copy.inspector.emojiLabel}</Label>
|
||||
<Input
|
||||
value={element.emoji || ''}
|
||||
onChange={(event) => onUpdateElement({ emoji: event.target.value })}
|
||||
placeholder={copy.inspector.emojiPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.fontSize, { value: element.fontSize })}</Label>
|
||||
<Slider
|
||||
value={[element.fontSize || 32]}
|
||||
onValueChange={([value]) => onUpdateElement({ fontSize: value })}
|
||||
min={12}
|
||||
max={200}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{element.type === 'text' && (
|
||||
<div>
|
||||
<Label>{copy.inspector.fontWeight}</Label>
|
||||
<Select value={element.fontWeight || 'bold'} onValueChange={(value) => onUpdateElement({ fontWeight: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">{copy.fontWeights.normal}</SelectItem>
|
||||
<SelectItem value="bold">{copy.fontWeights.bold}</SelectItem>
|
||||
<SelectItem value="lighter">{copy.fontWeights.lighter}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
46
src/components/FaviconDesigner/TransformInspector.tsx
Normal file
46
src/components/FaviconDesigner/TransformInspector.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { FaviconDesign, FaviconElement } from './types'
|
||||
|
||||
type TransformInspectorProps = {
|
||||
element: FaviconElement
|
||||
activeDesign: FaviconDesign
|
||||
onUpdateElement: (updates: Partial<FaviconElement>) => void
|
||||
}
|
||||
|
||||
export const TransformInspector = ({ element, activeDesign, onUpdateElement }: TransformInspectorProps) => (
|
||||
<>
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.xPosition, { value: element.x })}</Label>
|
||||
<Slider
|
||||
value={[element.x]}
|
||||
onValueChange={([value]) => onUpdateElement({ x: value })}
|
||||
min={0}
|
||||
max={activeDesign.size}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.yPosition, { value: element.y })}</Label>
|
||||
<Slider
|
||||
value={[element.y]}
|
||||
onValueChange={([value]) => onUpdateElement({ y: value })}
|
||||
min={0}
|
||||
max={activeDesign.size}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{formatCopy(copy.inspector.rotation, { value: element.rotation })}</Label>
|
||||
<Slider
|
||||
value={[element.rotation]}
|
||||
onValueChange={([value]) => onUpdateElement({ rotation: value })}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -8,24 +8,25 @@ import {
|
||||
TextT,
|
||||
Image as ImageIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { FaviconDesign } from './types'
|
||||
|
||||
export const PRESET_SIZES = [16, 32, 48, 64, 128, 256, 512]
|
||||
|
||||
export const ELEMENT_TYPES = [
|
||||
{ value: 'circle', label: 'Circle', icon: CircleNotch },
|
||||
{ value: 'square', label: 'Square', icon: Square },
|
||||
{ value: 'triangle', label: 'Triangle', icon: Triangle },
|
||||
{ value: 'star', label: 'Star', icon: Star },
|
||||
{ value: 'heart', label: 'Heart', icon: Heart },
|
||||
{ value: 'polygon', label: 'Polygon', icon: Polygon },
|
||||
{ value: 'text', label: 'Text', icon: TextT },
|
||||
{ value: 'emoji', label: 'Emoji', icon: ImageIcon },
|
||||
{ value: 'circle', icon: CircleNotch },
|
||||
{ value: 'square', icon: Square },
|
||||
{ value: 'triangle', icon: Triangle },
|
||||
{ value: 'star', icon: Star },
|
||||
{ value: 'heart', icon: Heart },
|
||||
{ value: 'polygon', icon: Polygon },
|
||||
{ value: 'text', icon: TextT },
|
||||
{ value: 'emoji', icon: ImageIcon },
|
||||
]
|
||||
|
||||
export const DEFAULT_DESIGN: FaviconDesign = {
|
||||
id: 'default',
|
||||
name: 'My Favicon',
|
||||
name: copy.defaults.designName,
|
||||
size: 128,
|
||||
backgroundColor: '#7c3aed',
|
||||
elements: [
|
||||
@@ -38,7 +39,7 @@ export const DEFAULT_DESIGN: FaviconDesign = {
|
||||
height: 100,
|
||||
color: '#ffffff',
|
||||
rotation: 0,
|
||||
text: 'CF',
|
||||
text: copy.defaults.designText,
|
||||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
5
src/components/FaviconDesigner/formatCopy.ts
Normal file
5
src/components/FaviconDesigner/formatCopy.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const formatCopy = (template: string, values: Record<string, string | number> = {}) =>
|
||||
template.replace(/\{(\w+)\}/g, (match, key: string) => {
|
||||
const value = values[key]
|
||||
return value === undefined ? match : String(value)
|
||||
})
|
||||
432
src/components/FaviconDesigner/useFaviconDesigner.ts
Normal file
432
src/components/FaviconDesigner/useFaviconDesigner.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import copy from '@/data/favicon-designer.json'
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
import { DEFAULT_DESIGN, PRESET_SIZES } from './constants'
|
||||
import { drawCanvas } from './canvasUtils'
|
||||
import { formatCopy } from './formatCopy'
|
||||
import { BrushEffect, FaviconDesign, FaviconElement } from './types'
|
||||
|
||||
export const useFaviconDesigner = () => {
|
||||
const [designs, setDesigns] = useKV<FaviconDesign[]>('favicon-designs', [DEFAULT_DESIGN])
|
||||
const [activeDesignId, setActiveDesignId] = useState<string>(DEFAULT_DESIGN.id)
|
||||
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [drawMode, setDrawMode] = useState<'select' | 'draw' | 'erase'>('select')
|
||||
const [brushSize, setBrushSize] = useState(3)
|
||||
const [brushColor, setBrushColor] = useState('#ffffff')
|
||||
const [brushEffect, setBrushEffect] = useState<BrushEffect>('solid')
|
||||
const [gradientColor, setGradientColor] = useState('#ff00ff')
|
||||
const [glowIntensity, setGlowIntensity] = useState(10)
|
||||
const [currentPath, setCurrentPath] = useState<Array<{ x: number; y: number }>>([])
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const drawingCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const safeDesigns = designs || [DEFAULT_DESIGN]
|
||||
const activeDesign = safeDesigns.find((d) => d.id === activeDesignId) || DEFAULT_DESIGN
|
||||
const selectedElement = activeDesign.elements.find((e) => e.id === selectedElementId)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (canvas) {
|
||||
drawCanvas(canvas, activeDesign)
|
||||
}
|
||||
}, [activeDesign])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = drawingCanvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
canvas.width = activeDesign.size
|
||||
canvas.height = activeDesign.size
|
||||
ctx.clearRect(0, 0, activeDesign.size, activeDesign.size)
|
||||
}, [activeDesign, drawMode])
|
||||
|
||||
const handleAddElement = (type: FaviconElement['type']) => {
|
||||
const newElement: FaviconElement = {
|
||||
id: `element-${Date.now()}`,
|
||||
type,
|
||||
x: activeDesign.size / 2,
|
||||
y: activeDesign.size / 2,
|
||||
width: type === 'text' || type === 'emoji' ? 100 : 40,
|
||||
height: type === 'text' || type === 'emoji' ? 100 : 40,
|
||||
color: '#ffffff',
|
||||
rotation: 0,
|
||||
...(type === 'text' && { text: copy.defaults.newText, fontSize: 32, fontWeight: 'bold' }),
|
||||
...(type === 'emoji' && { emoji: copy.defaults.newEmoji, fontSize: 40 }),
|
||||
}
|
||||
|
||||
setDesigns((current) =>
|
||||
(current || []).map((d) =>
|
||||
d.id === activeDesignId
|
||||
? { ...d, elements: [...d.elements, newElement], updatedAt: Date.now() }
|
||||
: d
|
||||
)
|
||||
)
|
||||
setSelectedElementId(newElement.id)
|
||||
}
|
||||
|
||||
const handleUpdateElement = (updates: Partial<FaviconElement>) => {
|
||||
if (!selectedElementId) return
|
||||
|
||||
setDesigns((current) =>
|
||||
(current || []).map((d) =>
|
||||
d.id === activeDesignId
|
||||
? {
|
||||
...d,
|
||||
elements: d.elements.map((e) => (e.id === selectedElementId ? { ...e, ...updates } : e)),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: d
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteElement = (elementId: string) => {
|
||||
setDesigns((current) =>
|
||||
(current || []).map((d) =>
|
||||
d.id === activeDesignId
|
||||
? { ...d, elements: d.elements.filter((e) => e.id !== elementId), updatedAt: Date.now() }
|
||||
: d
|
||||
)
|
||||
)
|
||||
setSelectedElementId(null)
|
||||
}
|
||||
|
||||
const handleUpdateDesign = (updates: Partial<FaviconDesign>) => {
|
||||
setDesigns((current) =>
|
||||
(current || []).map((d) => (d.id === activeDesignId ? { ...d, ...updates, updatedAt: Date.now() } : d))
|
||||
)
|
||||
}
|
||||
|
||||
const handleNewDesign = () => {
|
||||
const newDesign: FaviconDesign = {
|
||||
id: `design-${Date.now()}`,
|
||||
name: formatCopy(copy.design.newDesignName, { count: safeDesigns.length + 1 }),
|
||||
size: 128,
|
||||
backgroundColor: '#7c3aed',
|
||||
elements: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
setDesigns((current) => [...(current || []), newDesign])
|
||||
setActiveDesignId(newDesign.id)
|
||||
setSelectedElementId(null)
|
||||
}
|
||||
|
||||
const handleDuplicateDesign = () => {
|
||||
const newDesign: FaviconDesign = {
|
||||
...activeDesign,
|
||||
id: `design-${Date.now()}`,
|
||||
name: `${activeDesign.name}${copy.design.duplicateSuffix}`,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
setDesigns((current) => [...(current || []), newDesign])
|
||||
setActiveDesignId(newDesign.id)
|
||||
toast.success(copy.toasts.designDuplicated)
|
||||
}
|
||||
|
||||
const handleDeleteDesign = () => {
|
||||
if (safeDesigns.length === 1) {
|
||||
toast.error(copy.toasts.cannotDeleteLast)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredDesigns = safeDesigns.filter((d) => d.id !== activeDesignId)
|
||||
setDesigns(filteredDesigns)
|
||||
setActiveDesignId(filteredDesigns[0].id)
|
||||
setSelectedElementId(null)
|
||||
toast.success(copy.toasts.designDeleted)
|
||||
}
|
||||
|
||||
const generateSVG = (): string => {
|
||||
const size = activeDesign.size
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">`
|
||||
svg += `<rect width="${size}" height="${size}" fill="${activeDesign.backgroundColor}"/>`
|
||||
|
||||
activeDesign.elements.forEach((element) => {
|
||||
const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})`
|
||||
|
||||
switch (element.type) {
|
||||
case 'circle':
|
||||
svg += `<circle cx="0" cy="0" r="${element.width / 2}" fill="${element.color}" transform="${transform}"/>`
|
||||
break
|
||||
case 'square':
|
||||
svg += `<rect x="${-element.width / 2}" y="${-element.height / 2}" width="${element.width}" height="${element.height}" fill="${element.color}" transform="${transform}"/>`
|
||||
break
|
||||
case 'text':
|
||||
svg += `<text x="0" y="0" fill="${element.color}" font-size="${element.fontSize}" font-weight="${element.fontWeight}" text-anchor="middle" dominant-baseline="middle" transform="${transform}">${element.text}</text>`
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
svg += '</svg>'
|
||||
return svg
|
||||
}
|
||||
|
||||
const handleExport = (format: 'png' | 'ico' | 'svg', size?: number) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
if (format === 'png') {
|
||||
const exportSize = size || activeDesign.size
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
tempCanvas.width = exportSize
|
||||
tempCanvas.height = exportSize
|
||||
const ctx = tempCanvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, exportSize, exportSize)
|
||||
|
||||
tempCanvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${activeDesign.name}-${exportSize}x${exportSize}.png`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(formatCopy(copy.toasts.exportedPng, { size: exportSize }))
|
||||
})
|
||||
} else if (format === 'ico') {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${activeDesign.name}.ico`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(copy.toasts.exportedIco)
|
||||
})
|
||||
} else if (format === 'svg') {
|
||||
const svg = generateSVG()
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${activeDesign.name}.svg`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(copy.toasts.exportedSvg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportAll = () => {
|
||||
PRESET_SIZES.forEach((size) => {
|
||||
setTimeout(() => handleExport('png', size), size * 10)
|
||||
})
|
||||
toast.success(copy.toasts.exportAll)
|
||||
}
|
||||
|
||||
const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = drawingCanvasRef.current
|
||||
if (!canvas) return { x: 0, y: 0 }
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = activeDesign.size / rect.width
|
||||
const scaleY = activeDesign.size / rect.height
|
||||
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (drawMode === 'select') return
|
||||
|
||||
setIsDrawing(true)
|
||||
const coords = getCanvasCoordinates(e)
|
||||
setCurrentPath([coords])
|
||||
}
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || drawMode === 'select') return
|
||||
|
||||
const coords = getCanvasCoordinates(e)
|
||||
setCurrentPath((prev) => [...prev, coords])
|
||||
|
||||
const canvas = drawingCanvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
if (drawMode === 'draw') {
|
||||
if (brushEffect === 'glow') {
|
||||
ctx.shadowColor = brushColor
|
||||
ctx.shadowBlur = glowIntensity
|
||||
}
|
||||
|
||||
if (brushEffect === 'gradient' && currentPath.length > 0) {
|
||||
const gradient = ctx.createLinearGradient(currentPath[0].x, currentPath[0].y, coords.x, coords.y)
|
||||
gradient.addColorStop(0, brushColor)
|
||||
gradient.addColorStop(1, gradientColor)
|
||||
ctx.strokeStyle = gradient
|
||||
} else {
|
||||
ctx.strokeStyle = brushColor
|
||||
}
|
||||
|
||||
ctx.lineWidth = brushSize
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
if (currentPath.length > 0) {
|
||||
const prevPoint = currentPath[currentPath.length - 1]
|
||||
|
||||
if (brushEffect === 'spray') {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const offsetX = (Math.random() - 0.5) * brushSize * 2
|
||||
const offsetY = (Math.random() - 0.5) * brushSize * 2
|
||||
ctx.fillStyle = brushColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(coords.x + offsetX, coords.y + offsetY, brushSize / 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(prevPoint.x, prevPoint.y)
|
||||
ctx.lineTo(coords.x, coords.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0
|
||||
} else if (drawMode === 'erase') {
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.lineWidth = brushSize * 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
if (currentPath.length > 0) {
|
||||
const prevPoint = currentPath[currentPath.length - 1]
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(prevPoint.x, prevPoint.y)
|
||||
ctx.lineTo(coords.x, coords.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanvasMouseUp = () => {
|
||||
if (!isDrawing || drawMode === 'select') return
|
||||
|
||||
setIsDrawing(false)
|
||||
|
||||
if (drawMode === 'draw' && currentPath.length > 1) {
|
||||
const newElement: FaviconElement = {
|
||||
id: `element-${Date.now()}`,
|
||||
type: 'freehand',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
color: brushColor,
|
||||
rotation: 0,
|
||||
paths: currentPath,
|
||||
strokeWidth: brushSize,
|
||||
brushEffect,
|
||||
gradientColor: brushEffect === 'gradient' ? gradientColor : undefined,
|
||||
glowIntensity: brushEffect === 'glow' ? glowIntensity : undefined,
|
||||
}
|
||||
|
||||
setDesigns((current) =>
|
||||
(current || []).map((d) =>
|
||||
d.id === activeDesignId
|
||||
? { ...d, elements: [...d.elements, newElement], updatedAt: Date.now() }
|
||||
: d
|
||||
)
|
||||
)
|
||||
} else if (drawMode === 'erase') {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const filteredElements = activeDesign.elements.filter((element) => {
|
||||
if (element.type !== 'freehand' || !element.paths) return true
|
||||
|
||||
return !element.paths.some((point) =>
|
||||
currentPath.some((erasePoint) => {
|
||||
const distance = Math.sqrt(Math.pow(point.x - erasePoint.x, 2) + Math.pow(point.y - erasePoint.y, 2))
|
||||
return distance < brushSize * 2
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
if (filteredElements.length !== activeDesign.elements.length) {
|
||||
setDesigns((current) =>
|
||||
(current || []).map((d) =>
|
||||
d.id === activeDesignId
|
||||
? { ...d, elements: filteredElements, updatedAt: Date.now() }
|
||||
: d
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentPath([])
|
||||
const canvas = canvasRef.current
|
||||
if (canvas) {
|
||||
drawCanvas(canvas, activeDesign)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanvasMouseLeave = () => {
|
||||
if (isDrawing) {
|
||||
handleCanvasMouseUp()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeDesign,
|
||||
activeDesignId,
|
||||
brushColor,
|
||||
brushEffect,
|
||||
brushSize,
|
||||
canvasRef,
|
||||
drawMode,
|
||||
drawingCanvasRef,
|
||||
glowIntensity,
|
||||
gradientColor,
|
||||
safeDesigns,
|
||||
selectedElement,
|
||||
selectedElementId,
|
||||
setActiveDesignId,
|
||||
setBrushColor,
|
||||
setBrushEffect,
|
||||
setBrushSize,
|
||||
setDrawMode,
|
||||
setGlowIntensity,
|
||||
setGradientColor,
|
||||
setSelectedElementId,
|
||||
handleAddElement,
|
||||
handleCanvasMouseDown,
|
||||
handleCanvasMouseLeave,
|
||||
handleCanvasMouseMove,
|
||||
handleCanvasMouseUp,
|
||||
handleDeleteDesign,
|
||||
handleDeleteElement,
|
||||
handleDuplicateDesign,
|
||||
handleExport,
|
||||
handleExportAll,
|
||||
handleNewDesign,
|
||||
handleUpdateDesign,
|
||||
handleUpdateElement,
|
||||
}
|
||||
}
|
||||
120
src/data/favicon-designer.json
Normal file
120
src/data/favicon-designer.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"toolbar": {
|
||||
"newDesign": "New Design",
|
||||
"duplicate": "Duplicate",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"modes": {
|
||||
"select": "Select",
|
||||
"draw": "Draw",
|
||||
"erase": "Erase"
|
||||
},
|
||||
"toasts": {
|
||||
"designDuplicated": "Design duplicated",
|
||||
"cannotDeleteLast": "Cannot delete the last design",
|
||||
"designDeleted": "Design deleted",
|
||||
"exportedPng": "Exported as {size}x{size} PNG",
|
||||
"exportedIco": "Exported as ICO",
|
||||
"exportedSvg": "Exported as SVG",
|
||||
"exportAll": "Exporting all sizes..."
|
||||
},
|
||||
"canvas": {
|
||||
"exportPresetTitle": "Export {size}x{size}",
|
||||
"brushBadge": "{effect}: {size}px",
|
||||
"eraserBadge": "Eraser: {size}px",
|
||||
"presetLabel": "{size}px"
|
||||
},
|
||||
"export": {
|
||||
"png": "Export PNG",
|
||||
"svg": "Export SVG",
|
||||
"all": "Export All Sizes"
|
||||
},
|
||||
"design": {
|
||||
"nameLabel": "Design Name",
|
||||
"namePlaceholder": "My Favicon",
|
||||
"newDesignName": "Favicon {count}",
|
||||
"duplicateSuffix": " (Copy)",
|
||||
"selectLabel": "Select Design",
|
||||
"sizeLabel": "Canvas Size",
|
||||
"backgroundLabel": "Background Color",
|
||||
"backgroundPlaceholder": "#7c3aed",
|
||||
"filterLabel": "Image Filter",
|
||||
"filterIntensity": "Filter Intensity: {value}%"
|
||||
},
|
||||
"filters": {
|
||||
"none": "None",
|
||||
"blur": "Blur",
|
||||
"brightness": "Brightness",
|
||||
"contrast": "Contrast",
|
||||
"grayscale": "Grayscale",
|
||||
"sepia": "Sepia",
|
||||
"invert": "Invert",
|
||||
"saturate": "Saturate",
|
||||
"hue-rotate": "Hue Rotate",
|
||||
"pixelate": "Pixelate"
|
||||
},
|
||||
"elements": {
|
||||
"addTitle": "Add Elements",
|
||||
"selectHint": "Switch to Select mode to add elements",
|
||||
"listTitle": "Elements ({count})",
|
||||
"empty": "No elements yet. Add some or start drawing!"
|
||||
},
|
||||
"brush": {
|
||||
"settingsTitle": "Brush Settings",
|
||||
"eraserSettingsTitle": "Eraser Settings",
|
||||
"effectLabel": "Brush Effect",
|
||||
"colorLabel": "Brush Color",
|
||||
"gradientColorLabel": "Gradient End Color",
|
||||
"glowIntensity": "Glow Intensity: {value}px",
|
||||
"sizeLabel": "{mode} Size: {size}px"
|
||||
},
|
||||
"inspector": {
|
||||
"title": "Edit Element",
|
||||
"strokeColor": "Stroke Color",
|
||||
"strokeWidth": "Stroke Width: {value}px",
|
||||
"textLabel": "Text",
|
||||
"textPlaceholder": "Enter text",
|
||||
"emojiLabel": "Emoji",
|
||||
"emojiPlaceholder": "😀",
|
||||
"fontSize": "Font Size: {value}px",
|
||||
"fontWeight": "Font Weight",
|
||||
"width": "Width: {value}px",
|
||||
"height": "Height: {value}px",
|
||||
"xPosition": "X Position: {value}px",
|
||||
"yPosition": "Y Position: {value}px",
|
||||
"rotation": "Rotation: {value}°",
|
||||
"color": "Color"
|
||||
},
|
||||
"effects": {
|
||||
"solid": "Solid",
|
||||
"gradient": "Gradient",
|
||||
"spray": "Spray Paint",
|
||||
"glow": "Glow"
|
||||
},
|
||||
"fontWeights": {
|
||||
"normal": "Normal",
|
||||
"bold": "Bold",
|
||||
"lighter": "Light"
|
||||
},
|
||||
"elementTypes": {
|
||||
"circle": "Circle",
|
||||
"square": "Square",
|
||||
"triangle": "Triangle",
|
||||
"star": "Star",
|
||||
"heart": "Heart",
|
||||
"polygon": "Polygon",
|
||||
"text": "Text",
|
||||
"emoji": "Emoji",
|
||||
"freehand": "Freehand"
|
||||
},
|
||||
"defaults": {
|
||||
"designName": "My Favicon",
|
||||
"designText": "CF",
|
||||
"newText": "A",
|
||||
"newEmoji": "😀"
|
||||
},
|
||||
"placeholders": {
|
||||
"color": "#ffffff",
|
||||
"gradient": "#ff00ff"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user