diff --git a/src/components/FaviconDesigner.tsx b/src/components/FaviconDesigner.tsx index 29d8991..243f47e 100644 --- a/src/components/FaviconDesigner.tsx +++ b/src/components/FaviconDesigner.tsx @@ -24,13 +24,15 @@ import { Image as ImageIcon, ArrowCounterClockwise, Copy, - FloppyDisk + FloppyDisk, + PencilSimple, + Eraser } from '@phosphor-icons/react' import { toast } from 'sonner' interface FaviconElement { id: string - type: 'circle' | 'square' | 'triangle' | 'star' | 'heart' | 'polygon' | 'text' | 'emoji' + type: 'circle' | 'square' | 'triangle' | 'star' | 'heart' | 'polygon' | 'text' | 'emoji' | 'freehand' x: number y: number width: number @@ -41,6 +43,8 @@ interface FaviconElement { fontSize?: number fontWeight?: string emoji?: string + paths?: Array<{ x: number; y: number }> + strokeWidth?: number } interface FaviconDesign { @@ -93,7 +97,13 @@ 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 [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 @@ -119,49 +129,64 @@ export function FaviconDesigner() { activeDesign.elements.forEach((element) => { ctx.save() - ctx.translate(element.x, element.y) - ctx.rotate((element.rotation * Math.PI) / 180) - ctx.fillStyle = element.color + + if (element.type === 'freehand' && element.paths && element.paths.length > 0) { + ctx.strokeStyle = element.color + ctx.lineWidth = element.strokeWidth || 3 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + ctx.beginPath() + ctx.moveTo(element.paths[0].x, element.paths[0].y) + for (let i = 1; i < element.paths.length; i++) { + ctx.lineTo(element.paths[i].x, element.paths[i].y) + } + ctx.stroke() + } else { + ctx.translate(element.x, element.y) + ctx.rotate((element.rotation * Math.PI) / 180) + ctx.fillStyle = element.color - switch (element.type) { - case 'circle': - ctx.beginPath() - ctx.arc(0, 0, element.width / 2, 0, Math.PI * 2) - ctx.fill() - break - case 'square': - ctx.fillRect(-element.width / 2, -element.height / 2, element.width, element.height) - break - case 'triangle': - ctx.beginPath() - ctx.moveTo(0, -element.height / 2) - ctx.lineTo(element.width / 2, element.height / 2) - ctx.lineTo(-element.width / 2, element.height / 2) - ctx.closePath() - ctx.fill() - break - case 'star': - drawStar(ctx, 0, 0, 5, element.width / 2, element.width / 4) - break - case 'heart': - drawHeart(ctx, 0, 0, element.width) - break - case 'polygon': - drawPolygon(ctx, 0, 0, 6, element.width / 2) - break - case 'text': - ctx.fillStyle = element.color - ctx.font = `${element.fontWeight || 'bold'} ${element.fontSize || 32}px sans-serif` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText(element.text || '', 0, 0) - break - case 'emoji': - ctx.font = `${element.fontSize || 32}px sans-serif` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText(element.emoji || '😀', 0, 0) - break + switch (element.type) { + case 'circle': + ctx.beginPath() + ctx.arc(0, 0, element.width / 2, 0, Math.PI * 2) + ctx.fill() + break + case 'square': + ctx.fillRect(-element.width / 2, -element.height / 2, element.width, element.height) + break + case 'triangle': + ctx.beginPath() + ctx.moveTo(0, -element.height / 2) + ctx.lineTo(element.width / 2, element.height / 2) + ctx.lineTo(-element.width / 2, element.height / 2) + ctx.closePath() + ctx.fill() + break + case 'star': + drawStar(ctx, 0, 0, 5, element.width / 2, element.width / 4) + break + case 'heart': + drawHeart(ctx, 0, 0, element.width) + break + case 'polygon': + drawPolygon(ctx, 0, 0, 6, element.width / 2) + break + case 'text': + ctx.fillStyle = element.color + ctx.font = `${element.fontWeight || 'bold'} ${element.fontSize || 32}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(element.text || '', 0, 0) + break + case 'emoji': + ctx.font = `${element.fontSize || 32}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(element.emoji || '😀', 0, 0) + break + } } ctx.restore() @@ -403,6 +428,153 @@ export function FaviconDesigner() { 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') { + ctx.strokeStyle = brushColor + ctx.lineWidth = brushSize + 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() + } + } 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, + } + + 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([]) + drawCanvas() + } + + 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]) + + return (
@@ -421,6 +593,40 @@ export function FaviconDesigner() { Delete
+
+ + + +
@@ -430,18 +636,40 @@ export function FaviconDesigner() {
- +
+ + +
{activeDesign.size}x{activeDesign.size} + {drawMode !== 'select' && ( + + {drawMode === 'draw' ? `Brush: ${brushSize}px` : `Eraser: ${brushSize * 2}px`} + + )}
@@ -562,14 +790,63 @@ export function FaviconDesigner() { size="sm" onClick={() => handleAddElement(value as FaviconElement['type'])} className="flex flex-col gap-1 h-auto py-2" + disabled={drawMode !== 'select'} > {label} ))}
+ {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" + /> +
+
+ )} + +
+ + setBrushSize(value)} + min={1} + max={20} + step={1} + /> +
+
+ + )} +
@@ -586,16 +863,24 @@ export function FaviconDesigner() { ? 'border-primary bg-primary/10' : 'border-border hover:bg-accent/50' }`} - onClick={() => setSelectedElementId(element.id)} + onClick={() => { + if (drawMode === 'select') { + setSelectedElementId(element.id) + } + }} >
- {ELEMENT_TYPES.find((t) => t.value === element.type)?.icon && ( - - {(() => { - const Icon = ELEMENT_TYPES.find((t) => t.value === element.type)!.icon - return - })()} - + {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}"} @@ -615,19 +900,51 @@ export function FaviconDesigner() { ))} {activeDesign.elements.length === 0 && (

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

)}
- {selectedElement && ( + {selectedElement && drawMode === 'select' && ( <>
+ {selectedElement.type === 'freehand' && ( + <> +
+ +
+ handleUpdateElement({ color: e.target.value })} + className="w-20 h-10" + /> + handleUpdateElement({ color: e.target.value })} + placeholder="#ffffff" + /> +
+
+ +
+ + handleUpdateElement({ strokeWidth: value })} + min={1} + max={20} + step={1} + /> +
+ + )} + {(selectedElement.type === 'text' || selectedElement.type === 'emoji') && ( <> {selectedElement.type === 'text' && ( @@ -684,7 +1001,7 @@ export function FaviconDesigner() { )} - {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && ( + {selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && ( <>
@@ -710,55 +1027,61 @@ export function FaviconDesigner() { )} -
- - handleUpdateElement({ x: value })} - min={0} - 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({ y: value })} + min={0} + max={activeDesign.size} + step={1} + /> +
-
- - handleUpdateElement({ rotation: value })} - min={0} - max={360} - step={1} - /> -
+
+ + handleUpdateElement({ rotation: value })} + min={0} + max={360} + step={1} + /> +
+ + )} -
- -
- handleUpdateElement({ color: e.target.value })} - className="w-20 h-10" - /> - handleUpdateElement({ color: e.target.value })} - placeholder="#ffffff" - /> + {selectedElement.type !== 'freehand' && ( +
+ +
+ handleUpdateElement({ color: e.target.value })} + className="w-20 h-10" + /> + handleUpdateElement({ color: e.target.value })} + placeholder="#ffffff" + /> +
-
+ )}
)}