From 4adcf87903cce6d2c7c0344f408d2ffbc50e66a2 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 00:21:41 +0000 Subject: [PATCH] Refactor favicon designer components --- src/components/FaviconDesigner.tsx | 1203 ++--------------- .../FaviconDesigner/BrushSettingsPanel.tsx | 140 ++ .../FaviconDesigner/ColorInspector.tsx | 28 + .../FaviconDesigner/DesignSettingsPanel.tsx | 116 ++ .../FaviconDesigner/ElementInspectorPanel.tsx | 42 + .../FaviconDesigner/ElementsPanel.tsx | 104 ++ .../FaviconDesigner/FaviconDesignerCanvas.tsx | 127 ++ .../FaviconDesignerSidebar.tsx | 110 ++ .../FaviconDesignerToolbar.tsx | 57 + .../FaviconDesigner/FreehandInspector.tsx | 115 ++ .../FaviconDesigner/ShapeInspector.tsx | 36 + .../FaviconDesigner/TextEmojiInspector.tsx | 65 + .../FaviconDesigner/TransformInspector.tsx | 46 + src/components/FaviconDesigner/constants.ts | 21 +- src/components/FaviconDesigner/formatCopy.ts | 5 + .../FaviconDesigner/useFaviconDesigner.ts | 432 ++++++ src/data/favicon-designer.json | 120 ++ 17 files changed, 1651 insertions(+), 1116 deletions(-) create mode 100644 src/components/FaviconDesigner/BrushSettingsPanel.tsx create mode 100644 src/components/FaviconDesigner/ColorInspector.tsx create mode 100644 src/components/FaviconDesigner/DesignSettingsPanel.tsx create mode 100644 src/components/FaviconDesigner/ElementInspectorPanel.tsx create mode 100644 src/components/FaviconDesigner/ElementsPanel.tsx create mode 100644 src/components/FaviconDesigner/FaviconDesignerCanvas.tsx create mode 100644 src/components/FaviconDesigner/FaviconDesignerSidebar.tsx create mode 100644 src/components/FaviconDesigner/FaviconDesignerToolbar.tsx create mode 100644 src/components/FaviconDesigner/FreehandInspector.tsx create mode 100644 src/components/FaviconDesigner/ShapeInspector.tsx create mode 100644 src/components/FaviconDesigner/TextEmojiInspector.tsx create mode 100644 src/components/FaviconDesigner/TransformInspector.tsx create mode 100644 src/components/FaviconDesigner/formatCopy.ts create mode 100644 src/components/FaviconDesigner/useFaviconDesigner.ts create mode 100644 src/data/favicon-designer.json diff --git a/src/components/FaviconDesigner.tsx b/src/components/FaviconDesigner.tsx index efc762f..994db2b 100644 --- a/src/components/FaviconDesigner.tsx +++ b/src/components/FaviconDesigner.tsx @@ -1,1117 +1,108 @@ -import { useState, useRef, useEffect } from 'react' -import { useKV } from '@/hooks/use-kv' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -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 { ScrollArea } from '@/components/ui/scroll-area' -import { Separator } from '@/components/ui/separator' -import { Badge } from '@/components/ui/badge' -import { - Plus, - Trash, - Download, - Copy, - PencilSimple, - Eraser, - Gradient, - Sparkle, - Drop, -} from '@phosphor-icons/react' -import { toast } from 'sonner' -import { BrushEffect, CanvasFilter, FaviconElement, FaviconDesign } from './FaviconDesigner/types' -import { PRESET_SIZES, ELEMENT_TYPES, DEFAULT_DESIGN } from './FaviconDesigner/constants' -import { drawCanvas } from './FaviconDesigner/canvasUtils' +import { FaviconDesignerCanvas } from './FaviconDesigner/FaviconDesignerCanvas' +import { FaviconDesignerSidebar } from './FaviconDesigner/FaviconDesignerSidebar' +import { FaviconDesignerToolbar } from './FaviconDesigner/FaviconDesignerToolbar' +import { useFaviconDesigner } from './FaviconDesigner/useFaviconDesigner' export function FaviconDesigner() { - const [designs, setDesigns] = useKV('favicon-designs', [DEFAULT_DESIGN]) - const [activeDesignId, setActiveDesignId] = useState(DEFAULT_DESIGN.id) - const [selectedElementId, setSelectedElementId] = useState(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('solid') - const [gradientColor, setGradientColor] = useState('#ff00ff') - const [glowIntensity, setGlowIntensity] = useState(10) - const [currentPath, setCurrentPath] = useState>([]) - const canvasRef = useRef(null) - const drawingCanvasRef = useRef(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]) - - 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: 'A', fontSize: 32, fontWeight: 'bold' }), - ...(type === 'emoji' && { emoji: '😀', 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) => { - 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) => { - setDesigns((current) => - (current || []).map((d) => (d.id === activeDesignId ? { ...d, ...updates, updatedAt: Date.now() } : d)) - ) - } - - const handleNewDesign = () => { - const newDesign: FaviconDesign = { - id: `design-${Date.now()}`, - name: `Favicon ${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)`, - createdAt: Date.now(), - updatedAt: Date.now(), - } - - setDesigns((current) => [...(current || []), newDesign]) - setActiveDesignId(newDesign.id) - toast.success('Design duplicated') - } - - const handleDeleteDesign = () => { - if (safeDesigns.length === 1) { - toast.error('Cannot delete the last design') - return - } - - const filteredDesigns = safeDesigns.filter((d) => d.id !== activeDesignId) - setDesigns(filteredDesigns) - setActiveDesignId(filteredDesigns[0].id) - setSelectedElementId(null) - toast.success('Design deleted') - } - - 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(`Exported as ${exportSize}x${exportSize} PNG`) - }) - } 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('Exported as ICO') - }) - } 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('Exported as SVG') - } - } - - const generateSVG = (): string => { - const size = activeDesign.size - let svg = `` - svg += `` - - activeDesign.elements.forEach((element) => { - const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})` - - switch (element.type) { - case 'circle': - svg += `` - break - case 'square': - svg += `` - break - case 'text': - svg += `${element.text}` - break - } - }) - - svg += '' - return svg - } - - const handleExportAll = () => { - PRESET_SIZES.forEach((size) => { - setTimeout(() => handleExport('png', size), size * 10) - }) - toast.success('Exporting all sizes...') - } - - const getCanvasCoordinates = (e: React.MouseEvent) => { - 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) => { - if (drawMode === 'select') return - - setIsDrawing(true) - const coords = getCanvasCoordinates(e) - setCurrentPath([coords]) - } - - const handleCanvasMouseMove = (e: React.MouseEvent) => { - 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: 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 imageData = ctx.getImageData(0, 0, activeDesign.size, activeDesign.size) - - 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() - } - } - - 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 { + 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, + } = useFaviconDesigner() return (
-
-
-
- - - -
-
- - - -
-
-
+ 1} + onNewDesign={handleNewDesign} + onDuplicateDesign={handleDuplicateDesign} + onDeleteDesign={handleDeleteDesign} + onSelectMode={() => { + setDrawMode('select') + setSelectedElementId(null) + }} + onDrawMode={() => { + setDrawMode('draw') + setSelectedElementId(null) + }} + onEraseMode={() => { + setDrawMode('erase') + setSelectedElementId(null) + }} + />
-
- -
-
-
- - -
- - {activeDesign.size}x{activeDesign.size} - - {drawMode !== 'select' && ( - - {drawMode === 'draw' - ? `${brushEffect.charAt(0).toUpperCase() + brushEffect.slice(1)}: ${brushSize}px` - : `Eraser: ${brushSize * 2}px`} - - )} -
- -
- {PRESET_SIZES.map((size) => ( -
handleExport('png', size)} - title={`Export ${size}x${size}`} - > - { - 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` }} - /> - {size}px -
- ))} -
-
-
- -
- - - -
-
- - -
-
- - handleUpdateDesign({ name: e.target.value })} - placeholder="My Favicon" - /> -
- -
- - -
- -
- - -
- -
- -
- handleUpdateDesign({ backgroundColor: e.target.value })} - className="w-20 h-10" - /> - handleUpdateDesign({ backgroundColor: e.target.value })} - placeholder="#7c3aed" - /> -
-
- -
- - -
- - {activeDesign.filter && activeDesign.filter !== 'none' && ( -
- - handleUpdateDesign({ filterIntensity: value })} - min={0} - max={100} - step={1} - /> -
- )} - - - -
- -
- {ELEMENT_TYPES.map(({ value, label, icon: Icon }) => ( - - ))} -
- {drawMode !== 'select' && ( -

- Switch to Select mode to add elements -

- )} -
- - {drawMode !== 'select' && ( - <> - -
- - - {drawMode === 'draw' && ( - <> -
- - -
- -
- -
- setBrushColor(e.target.value)} - className="w-20 h-10" - /> - setBrushColor(e.target.value)} - placeholder="#ffffff" - /> -
-
- - {brushEffect === 'gradient' && ( -
- -
- setGradientColor(e.target.value)} - className="w-20 h-10" - /> - setGradientColor(e.target.value)} - placeholder="#ff00ff" - /> -
-
- )} - - {brushEffect === 'glow' && ( -
- - setGlowIntensity(value)} - min={1} - max={30} - step={1} - /> -
- )} - - )} - -
- - setBrushSize(value)} - min={1} - max={20} - step={1} - /> -
-
- - )} - - - -
- - -
- {activeDesign.elements.map((element) => ( -
{ - if (drawMode === 'select') { - setSelectedElementId(element.id) - } - }} - > -
- {element.type === 'freehand' ? ( - - ) : ( - ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && ( - - {(() => { - const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon - return - })()} - - ) - )} - {element.type} - {element.text && "{element.text}"} - {element.emoji && {element.emoji}} -
- -
- ))} - {activeDesign.elements.length === 0 && ( -

- No elements yet. Add some or start drawing! -

- )} -
-
-
- - {selectedElement && drawMode === 'select' && ( - <> - -
- - - {selectedElement.type === 'freehand' && ( - <> -
- - -
- -
- -
- handleUpdateElement({ color: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ color: e.target.value })} - placeholder="#ffffff" - /> -
-
- - {selectedElement.brushEffect === 'gradient' && ( -
- -
- handleUpdateElement({ gradientColor: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ gradientColor: e.target.value })} - placeholder="#ff00ff" - /> -
-
- )} - - {selectedElement.brushEffect === 'glow' && ( -
- - handleUpdateElement({ glowIntensity: value })} - min={1} - max={30} - step={1} - /> -
- )} - -
- - handleUpdateElement({ strokeWidth: value })} - min={1} - max={20} - step={1} - /> -
- - )} - - {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && ( - <> - {selectedElement.type === 'text' && ( -
- - handleUpdateElement({ text: e.target.value })} - placeholder="Enter text" - /> -
- )} - - {selectedElement.type === 'emoji' && ( -
- - handleUpdateElement({ emoji: e.target.value })} - placeholder="😀" - /> -
- )} - -
- - handleUpdateElement({ fontSize: value })} - min={12} - max={200} - step={1} - /> -
- - {selectedElement.type === 'text' && ( -
- - -
- )} - - )} - - {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && ( - <> -
- - handleUpdateElement({ width: value })} - min={10} - max={activeDesign.size} - step={1} - /> -
- -
- - handleUpdateElement({ height: value })} - min={10} - max={activeDesign.size} - step={1} - /> -
- - )} - - {selectedElement.type !== 'freehand' && ( - <> -
- - handleUpdateElement({ x: value })} - min={0} - max={activeDesign.size} - step={1} - /> -
- -
- - handleUpdateElement({ y: value })} - min={0} - max={activeDesign.size} - step={1} - /> -
- -
- - handleUpdateElement({ rotation: value })} - min={0} - max={360} - step={1} - /> -
- - )} - - {selectedElement.type !== 'freehand' && ( -
- -
- handleUpdateElement({ color: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ color: e.target.value })} - placeholder="#ffffff" - /> -
-
- )} -
- - )} -
-
+ +
diff --git a/src/components/FaviconDesigner/BrushSettingsPanel.tsx b/src/components/FaviconDesigner/BrushSettingsPanel.tsx new file mode 100644 index 0000000..8744023 --- /dev/null +++ b/src/components/FaviconDesigner/BrushSettingsPanel.tsx @@ -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) => ( +
+ + + {drawMode === 'draw' && ( + <> +
+ + +
+ +
+ +
+ onBrushColorChange(event.target.value)} + className="w-20 h-10" + /> + onBrushColorChange(event.target.value)} + placeholder={copy.placeholders.color} + /> +
+
+ + {brushEffect === 'gradient' && ( +
+ +
+ onGradientColorChange(event.target.value)} + className="w-20 h-10" + /> + onGradientColorChange(event.target.value)} + placeholder={copy.placeholders.gradient} + /> +
+
+ )} + + {brushEffect === 'glow' && ( +
+ + onGlowIntensityChange(value)} + min={1} + max={30} + step={1} + /> +
+ )} + + )} + +
+ + onBrushSizeChange(value)} min={1} max={20} step={1} /> +
+
+) diff --git a/src/components/FaviconDesigner/ColorInspector.tsx b/src/components/FaviconDesigner/ColorInspector.tsx new file mode 100644 index 0000000..152ad31 --- /dev/null +++ b/src/components/FaviconDesigner/ColorInspector.tsx @@ -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) => void +} + +export const ColorInspector = ({ element, onUpdateElement }: ColorInspectorProps) => ( +
+ +
+ onUpdateElement({ color: event.target.value })} + className="w-20 h-10" + /> + onUpdateElement({ color: event.target.value })} + placeholder={copy.placeholders.color} + /> +
+
+) diff --git a/src/components/FaviconDesigner/DesignSettingsPanel.tsx b/src/components/FaviconDesigner/DesignSettingsPanel.tsx new file mode 100644 index 0000000..3f0baaf --- /dev/null +++ b/src/components/FaviconDesigner/DesignSettingsPanel.tsx @@ -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) => void + onSelectDesign: (value: string) => void +} + +export const DesignSettingsPanel = ({ + activeDesign, + activeDesignId, + designs, + onUpdateDesign, + onSelectDesign, +}: DesignSettingsPanelProps) => ( +
+
+ + onUpdateDesign({ name: e.target.value })} + placeholder={copy.design.namePlaceholder} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ onUpdateDesign({ backgroundColor: e.target.value })} + className="w-20 h-10" + /> + onUpdateDesign({ backgroundColor: e.target.value })} + placeholder={copy.design.backgroundPlaceholder} + /> +
+
+ +
+ + +
+ + {activeDesign.filter && activeDesign.filter !== 'none' && ( +
+ + onUpdateDesign({ filterIntensity: value })} + min={0} + max={100} + step={1} + /> +
+ )} +
+) diff --git a/src/components/FaviconDesigner/ElementInspectorPanel.tsx b/src/components/FaviconDesigner/ElementInspectorPanel.tsx new file mode 100644 index 0000000..ae466a8 --- /dev/null +++ b/src/components/FaviconDesigner/ElementInspectorPanel.tsx @@ -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) => void +} + +export const ElementInspectorPanel = ({ + activeDesign, + selectedElement, + onUpdateElement, +}: ElementInspectorPanelProps) => ( +
+ + + {selectedElement.type === 'freehand' && ( + + )} + + {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && ( + + )} + + {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && ( + + )} + + {selectedElement.type !== 'freehand' && ( + + )} + + {selectedElement.type !== 'freehand' && } +
+) diff --git a/src/components/FaviconDesigner/ElementsPanel.tsx b/src/components/FaviconDesigner/ElementsPanel.tsx new file mode 100644 index 0000000..ee1c5ec --- /dev/null +++ b/src/components/FaviconDesigner/ElementsPanel.tsx @@ -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) => ( +
+
+ +
+ {ELEMENT_TYPES.map(({ value, icon: Icon }) => ( + + ))} +
+ {drawMode !== 'select' &&

{copy.elements.selectHint}

} +
+ +
+ + +
+ {activeDesign.elements.map((element) => ( +
{ + if (drawMode === 'select') { + onSelectElement(element.id) + } + }} + > +
+ {element.type === 'freehand' ? ( + + ) : ( + ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && ( + + {(() => { + const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon + return + })()} + + ) + )} + + {copy.elementTypes[element.type as keyof typeof copy.elementTypes] || element.type} + + {element.text && "{element.text}"} + {element.emoji && {element.emoji}} +
+ +
+ ))} + {activeDesign.elements.length === 0 && ( +

{copy.elements.empty}

+ )} +
+
+
+
+) diff --git a/src/components/FaviconDesigner/FaviconDesignerCanvas.tsx b/src/components/FaviconDesigner/FaviconDesignerCanvas.tsx new file mode 100644 index 0000000..0ba3684 --- /dev/null +++ b/src/components/FaviconDesigner/FaviconDesignerCanvas.tsx @@ -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 + drawingCanvasRef: React.RefObject + drawMode: 'select' | 'draw' | 'erase' + onExport: (format: 'png' | 'ico' | 'svg', size?: number) => void + onExportAll: () => void + onMouseDown: (event: React.MouseEvent) => void + onMouseMove: (event: React.MouseEvent) => void + onMouseUp: () => void + onMouseLeave: () => void +} + +export const FaviconDesignerCanvas = ({ + activeSize, + brushEffect, + brushSize, + canvasRef, + drawingCanvasRef, + drawMode, + onExport, + onExportAll, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave, +}: FaviconDesignerCanvasProps) => ( +
+ +
+
+
+ + +
+ + {activeSize}x{activeSize} + + {drawMode !== 'select' && ( + + {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 })} + + )} +
+ +
+ {PRESET_SIZES.map((size) => ( +
onExport('png', size)} + title={formatCopy(copy.canvas.exportPresetTitle, { size })} + > + { + 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` }} + /> + + {formatCopy(copy.canvas.presetLabel, { size })} + +
+ ))} +
+
+
+ +
+ + + +
+
+) diff --git a/src/components/FaviconDesigner/FaviconDesignerSidebar.tsx b/src/components/FaviconDesigner/FaviconDesignerSidebar.tsx new file mode 100644 index 0000000..710e487 --- /dev/null +++ b/src/components/FaviconDesigner/FaviconDesignerSidebar.tsx @@ -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) => void + onUpdateElement: (updates: Partial) => 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) => ( + +
+ + + + + + + {drawMode !== 'select' && ( + <> + + + + )} + + {selectedElement && drawMode === 'select' && ( + <> + + + + )} +
+
+) diff --git a/src/components/FaviconDesigner/FaviconDesignerToolbar.tsx b/src/components/FaviconDesigner/FaviconDesignerToolbar.tsx new file mode 100644 index 0000000..cb9d14d --- /dev/null +++ b/src/components/FaviconDesigner/FaviconDesignerToolbar.tsx @@ -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) => ( +
+
+
+ + + +
+
+ + + +
+
+
+) diff --git a/src/components/FaviconDesigner/FreehandInspector.tsx b/src/components/FaviconDesigner/FreehandInspector.tsx new file mode 100644 index 0000000..97845d2 --- /dev/null +++ b/src/components/FaviconDesigner/FreehandInspector.tsx @@ -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) => void +} + +export const FreehandInspector = ({ element, onUpdateElement }: FreehandInspectorProps) => ( + <> +
+ + +
+ +
+ +
+ onUpdateElement({ color: event.target.value })} + className="w-20 h-10" + /> + onUpdateElement({ color: event.target.value })} + placeholder={copy.placeholders.color} + /> +
+
+ + {element.brushEffect === 'gradient' && ( +
+ +
+ onUpdateElement({ gradientColor: event.target.value })} + className="w-20 h-10" + /> + onUpdateElement({ gradientColor: event.target.value })} + placeholder={copy.placeholders.gradient} + /> +
+
+ )} + + {element.brushEffect === 'glow' && ( +
+ + onUpdateElement({ glowIntensity: value })} + min={1} + max={30} + step={1} + /> +
+ )} + +
+ + onUpdateElement({ strokeWidth: value })} + min={1} + max={20} + step={1} + /> +
+ +) diff --git a/src/components/FaviconDesigner/ShapeInspector.tsx b/src/components/FaviconDesigner/ShapeInspector.tsx new file mode 100644 index 0000000..edb2a59 --- /dev/null +++ b/src/components/FaviconDesigner/ShapeInspector.tsx @@ -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) => void +} + +export const ShapeInspector = ({ element, activeDesign, onUpdateElement }: ShapeInspectorProps) => ( + <> +
+ + onUpdateElement({ width: value })} + min={10} + max={activeDesign.size} + step={1} + /> +
+
+ + onUpdateElement({ height: value })} + min={10} + max={activeDesign.size} + step={1} + /> +
+ +) diff --git a/src/components/FaviconDesigner/TextEmojiInspector.tsx b/src/components/FaviconDesigner/TextEmojiInspector.tsx new file mode 100644 index 0000000..1fcd145 --- /dev/null +++ b/src/components/FaviconDesigner/TextEmojiInspector.tsx @@ -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) => void +} + +export const TextEmojiInspector = ({ element, onUpdateElement }: TextEmojiInspectorProps) => ( + <> + {element.type === 'text' && ( +
+ + onUpdateElement({ text: event.target.value })} + placeholder={copy.inspector.textPlaceholder} + /> +
+ )} + + {element.type === 'emoji' && ( +
+ + onUpdateElement({ emoji: event.target.value })} + placeholder={copy.inspector.emojiPlaceholder} + /> +
+ )} + +
+ + onUpdateElement({ fontSize: value })} + min={12} + max={200} + step={1} + /> +
+ + {element.type === 'text' && ( +
+ + +
+ )} + +) diff --git a/src/components/FaviconDesigner/TransformInspector.tsx b/src/components/FaviconDesigner/TransformInspector.tsx new file mode 100644 index 0000000..292e371 --- /dev/null +++ b/src/components/FaviconDesigner/TransformInspector.tsx @@ -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) => void +} + +export const TransformInspector = ({ element, activeDesign, onUpdateElement }: TransformInspectorProps) => ( + <> +
+ + onUpdateElement({ x: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
+
+ + onUpdateElement({ y: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
+
+ + onUpdateElement({ rotation: value })} + min={0} + max={360} + step={1} + /> +
+ +) diff --git a/src/components/FaviconDesigner/constants.ts b/src/components/FaviconDesigner/constants.ts index 70c6c78..366ef58 100644 --- a/src/components/FaviconDesigner/constants.ts +++ b/src/components/FaviconDesigner/constants.ts @@ -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', }, diff --git a/src/components/FaviconDesigner/formatCopy.ts b/src/components/FaviconDesigner/formatCopy.ts new file mode 100644 index 0000000..ff1057b --- /dev/null +++ b/src/components/FaviconDesigner/formatCopy.ts @@ -0,0 +1,5 @@ +export const formatCopy = (template: string, values: Record = {}) => + template.replace(/\{(\w+)\}/g, (match, key: string) => { + const value = values[key] + return value === undefined ? match : String(value) + }) diff --git a/src/components/FaviconDesigner/useFaviconDesigner.ts b/src/components/FaviconDesigner/useFaviconDesigner.ts new file mode 100644 index 0000000..738ad08 --- /dev/null +++ b/src/components/FaviconDesigner/useFaviconDesigner.ts @@ -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('favicon-designs', [DEFAULT_DESIGN]) + const [activeDesignId, setActiveDesignId] = useState(DEFAULT_DESIGN.id) + const [selectedElementId, setSelectedElementId] = useState(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('solid') + const [gradientColor, setGradientColor] = useState('#ff00ff') + const [glowIntensity, setGlowIntensity] = useState(10) + const [currentPath, setCurrentPath] = useState>([]) + const canvasRef = useRef(null) + const drawingCanvasRef = useRef(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) => { + 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) => { + 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 += `` + + activeDesign.elements.forEach((element) => { + const transform = `translate(${element.x},${element.y}) rotate(${element.rotation})` + + switch (element.type) { + case 'circle': + svg += `` + break + case 'square': + svg += `` + break + case 'text': + svg += `${element.text}` + break + } + }) + + 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) => { + 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) => { + if (drawMode === 'select') return + + setIsDrawing(true) + const coords = getCanvasCoordinates(e) + setCurrentPath([coords]) + } + + const handleCanvasMouseMove = (e: React.MouseEvent) => { + 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, + } +} diff --git a/src/data/favicon-designer.json b/src/data/favicon-designer.json new file mode 100644 index 0000000..7d310a7 --- /dev/null +++ b/src/data/favicon-designer.json @@ -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" + } +}