Files
low-code-react-app-b/src/components/FaviconDesigner.tsx

1440 lines
52 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
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,
CircleNotch,
Square,
Triangle,
Star,
Heart,
Polygon,
TextT,
Image as ImageIcon,
ArrowCounterClockwise,
Copy,
FloppyDisk,
PencilSimple,
Eraser,
Gradient,
Sparkle,
Drop,
MagicWand
} from '@phosphor-icons/react'
import { toast } from 'sonner'
type BrushEffect = 'solid' | 'gradient' | 'spray' | 'glow'
type CanvasFilter = 'none' | 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'sepia' | 'invert' | 'saturate' | 'hue-rotate' | 'pixelate'
interface FaviconElement {
id: string
type: 'circle' | 'square' | 'triangle' | 'star' | 'heart' | 'polygon' | 'text' | 'emoji' | 'freehand'
x: number
y: number
width: number
height: number
color: string
rotation: number
text?: string
fontSize?: number
fontWeight?: string
emoji?: string
paths?: Array<{ x: number; y: number }>
strokeWidth?: number
brushEffect?: BrushEffect
gradientColor?: string
glowIntensity?: number
}
interface FaviconDesign {
id: string
name: string
size: number
backgroundColor: string
elements: FaviconElement[]
createdAt: number
updatedAt: number
filter?: CanvasFilter
filterIntensity?: number
}
const PRESET_SIZES = [16, 32, 48, 64, 128, 256, 512]
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 },
]
const DEFAULT_DESIGN: FaviconDesign = {
id: 'default',
name: 'My Favicon',
size: 128,
backgroundColor: '#7c3aed',
elements: [
{
id: '1',
type: 'text',
x: 64,
y: 64,
width: 100,
height: 100,
color: '#ffffff',
rotation: 0,
text: 'CF',
fontSize: 48,
fontWeight: 'bold',
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
}
export function FaviconDesigner() {
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(() => {
drawCanvas()
}, [activeDesign])
const drawCanvas = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const size = activeDesign.size
canvas.width = size
canvas.height = size
ctx.fillStyle = activeDesign.backgroundColor
ctx.fillRect(0, 0, size, size)
activeDesign.elements.forEach((element) => {
ctx.save()
if (element.type === 'freehand' && element.paths && element.paths.length > 0) {
const effect = element.brushEffect || 'solid'
const strokeWidth = element.strokeWidth || 3
if (effect === 'glow') {
ctx.shadowColor = element.color
ctx.shadowBlur = element.glowIntensity || 10
}
if (effect === 'gradient' && element.gradientColor) {
const bounds = getPathBounds(element.paths)
const gradient = ctx.createLinearGradient(
bounds.minX,
bounds.minY,
bounds.maxX,
bounds.maxY
)
gradient.addColorStop(0, element.color)
gradient.addColorStop(1, element.gradientColor)
ctx.strokeStyle = gradient
} else {
ctx.strokeStyle = element.color
}
ctx.lineWidth = strokeWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
if (effect === 'spray') {
element.paths.forEach((point, i) => {
if (i % 2 === 0) {
for (let j = 0; j < 3; j++) {
const offsetX = (Math.random() - 0.5) * strokeWidth * 2
const offsetY = (Math.random() - 0.5) * strokeWidth * 2
ctx.fillStyle = element.color
ctx.beginPath()
ctx.arc(point.x + offsetX, point.y + offsetY, strokeWidth / 3, 0, Math.PI * 2)
ctx.fill()
}
}
})
} else {
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()
}
ctx.shadowBlur = 0
} 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
}
}
ctx.restore()
})
if (activeDesign.filter && activeDesign.filter !== 'none') {
applyCanvasFilter(ctx, activeDesign.filter, activeDesign.filterIntensity || 50)
}
}
const getPathBounds = (paths: Array<{ x: number; y: number }>) => {
const xs = paths.map(p => p.x)
const ys = paths.map(p => p.y)
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
}
}
const applyCanvasFilter = (ctx: CanvasRenderingContext2D, filter: CanvasFilter, intensity: number) => {
const canvas = ctx.canvas
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
switch (filter) {
case 'blur':
ctx.filter = `blur(${intensity / 10}px)`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'brightness':
ctx.filter = `brightness(${intensity / 50})`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'contrast':
ctx.filter = `contrast(${intensity / 50})`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'grayscale':
ctx.filter = `grayscale(${intensity / 100})`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'sepia':
ctx.filter = `sepia(${intensity / 100})`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'invert':
ctx.filter = `invert(${intensity / 100})`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'saturate':
ctx.filter = `saturate(${intensity / 50})`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'hue-rotate':
ctx.filter = `hue-rotate(${intensity * 3.6}deg)`
ctx.drawImage(canvas, 0, 0)
ctx.filter = 'none'
break
case 'pixelate':
const pixelSize = Math.max(1, Math.floor(intensity / 10))
const tempCanvas = document.createElement('canvas')
tempCanvas.width = canvas.width / pixelSize
tempCanvas.height = canvas.height / pixelSize
const tempCtx = tempCanvas.getContext('2d')
if (tempCtx) {
tempCtx.imageSmoothingEnabled = false
tempCtx.drawImage(canvas, 0, 0, tempCanvas.width, tempCanvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = true
}
break
}
}
const drawStar = (ctx: CanvasRenderingContext2D, cx: number, cy: number, spikes: number, outerRadius: number, innerRadius: number) => {
let rot = (Math.PI / 2) * 3
let x = cx
let y = cy
const step = Math.PI / spikes
ctx.beginPath()
ctx.moveTo(cx, cy - outerRadius)
for (let i = 0; i < spikes; i++) {
x = cx + Math.cos(rot) * outerRadius
y = cy + Math.sin(rot) * outerRadius
ctx.lineTo(x, y)
rot += step
x = cx + Math.cos(rot) * innerRadius
y = cy + Math.sin(rot) * innerRadius
ctx.lineTo(x, y)
rot += step
}
ctx.lineTo(cx, cy - outerRadius)
ctx.closePath()
ctx.fill()
}
const drawHeart = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => {
const topCurveHeight = size * 0.3
ctx.beginPath()
ctx.moveTo(x, y + topCurveHeight)
ctx.bezierCurveTo(x, y, x - size / 2, y - topCurveHeight, x - size / 2, y + topCurveHeight)
ctx.bezierCurveTo(x - size / 2, y + (size + topCurveHeight) / 2, x, y + (size + topCurveHeight) / 1.2, x, y + size)
ctx.bezierCurveTo(x, y + (size + topCurveHeight) / 1.2, x + size / 2, y + (size + topCurveHeight) / 2, x + size / 2, y + topCurveHeight)
ctx.bezierCurveTo(x + size / 2, y - topCurveHeight, x, y, x, y + topCurveHeight)
ctx.closePath()
ctx.fill()
}
const drawPolygon = (ctx: CanvasRenderingContext2D, x: number, y: number, sides: number, radius: number) => {
ctx.beginPath()
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
const px = x + radius * Math.cos(angle)
const py = y + radius * Math.sin(angle)
if (i === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
}
ctx.closePath()
ctx.fill()
}
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<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: `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 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 handleExportAll = () => {
PRESET_SIZES.forEach((size) => {
setTimeout(() => handleExport('png', size), size * 10)
})
toast.success('Exporting all sizes...')
}
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: 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([])
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 (
<div className="h-full flex flex-col bg-background">
<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={handleNewDesign}>
<Plus size={16} className="mr-2" />
New Design
</Button>
<Button variant="outline" size="sm" onClick={handleDuplicateDesign}>
<Copy size={16} className="mr-2" />
Duplicate
</Button>
<Button variant="outline" size="sm" onClick={handleDeleteDesign} disabled={safeDesigns.length === 1}>
<Trash size={16} className="mr-2" />
Delete
</Button>
</div>
<div className="flex gap-2">
<Button
variant={drawMode === 'select' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setDrawMode('select')
setSelectedElementId(null)
}}
>
Select
</Button>
<Button
variant={drawMode === 'draw' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setDrawMode('draw')
setSelectedElementId(null)
}}
>
<PencilSimple size={16} className="mr-2" />
Draw
</Button>
<Button
variant={drawMode === 'erase' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setDrawMode('erase')
setSelectedElementId(null)
}}
>
<Eraser size={16} className="mr-2" />
Erase
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
<div className="h-full grid grid-cols-1 lg:grid-cols-[1fr_400px]">
<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={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onMouseLeave={handleCanvasMouseLeave}
/>
</div>
<Badge className="absolute -top-3 -right-3">
{activeDesign.size}x{activeDesign.size}
</Badge>
{drawMode !== 'select' && (
<Badge className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-accent">
{drawMode === 'draw'
? `${brushEffect.charAt(0).toUpperCase() + brushEffect.slice(1)}: ${brushSize}px`
: `Eraser: ${brushSize * 2}px`}
</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={() => handleExport('png', size)}
title={`Export ${size}x${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">{size}px</span>
</div>
))}
</div>
</div>
</Card>
<div className="flex gap-2">
<Button onClick={() => handleExport('png')}>
<Download size={16} className="mr-2" />
Export PNG
</Button>
<Button onClick={() => handleExport('svg')} variant="outline">
<Download size={16} className="mr-2" />
Export SVG
</Button>
<Button onClick={handleExportAll} variant="outline">
<Download size={16} className="mr-2" />
Export All Sizes
</Button>
</div>
</div>
<ScrollArea className="h-full">
<div className="p-6 space-y-6">
<div>
<Label>Design Name</Label>
<Input
value={activeDesign.name}
onChange={(e) => handleUpdateDesign({ name: e.target.value })}
placeholder="My Favicon"
/>
</div>
<div>
<Label>Select Design</Label>
<Select value={activeDesignId} onValueChange={setActiveDesignId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{safeDesigns.map((design) => (
<SelectItem key={design.id} value={design.id}>
{design.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Canvas Size</Label>
<Select
value={String(activeDesign.size)}
onValueChange={(value) => handleUpdateDesign({ 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>Background Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={activeDesign.backgroundColor}
onChange={(e) => handleUpdateDesign({ backgroundColor: e.target.value })}
className="w-20 h-10"
/>
<Input
value={activeDesign.backgroundColor}
onChange={(e) => handleUpdateDesign({ backgroundColor: e.target.value })}
placeholder="#7c3aed"
/>
</div>
</div>
<div>
<Label>Image Filter</Label>
<Select
value={activeDesign.filter || 'none'}
onValueChange={(value) => handleUpdateDesign({ filter: value as CanvasFilter })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="blur">Blur</SelectItem>
<SelectItem value="brightness">Brightness</SelectItem>
<SelectItem value="contrast">Contrast</SelectItem>
<SelectItem value="grayscale">Grayscale</SelectItem>
<SelectItem value="sepia">Sepia</SelectItem>
<SelectItem value="invert">Invert</SelectItem>
<SelectItem value="saturate">Saturate</SelectItem>
<SelectItem value="hue-rotate">Hue Rotate</SelectItem>
<SelectItem value="pixelate">Pixelate</SelectItem>
</SelectContent>
</Select>
</div>
{activeDesign.filter && activeDesign.filter !== 'none' && (
<div>
<Label>Filter Intensity: {activeDesign.filterIntensity || 50}%</Label>
<Slider
value={[activeDesign.filterIntensity || 50]}
onValueChange={([value]) => handleUpdateDesign({ filterIntensity: value })}
min={0}
max={100}
step={1}
/>
</div>
)}
<Separator />
<div>
<Label className="mb-3 block">Add Elements</Label>
<div className="grid grid-cols-4 gap-2">
{ELEMENT_TYPES.map(({ value, label, icon: Icon }) => (
<Button
key={value}
variant="outline"
size="sm"
onClick={() => handleAddElement(value as FaviconElement['type'])}
className="flex flex-col gap-1 h-auto py-2"
disabled={drawMode !== 'select'}
>
<Icon size={20} />
<span className="text-xs">{label}</span>
</Button>
))}
</div>
{drawMode !== 'select' && (
<p className="text-xs text-muted-foreground mt-2">
Switch to Select mode to add elements
</p>
)}
</div>
{drawMode !== 'select' && (
<>
<Separator />
<div className="space-y-4">
<Label className="text-base font-semibold">
{drawMode === 'draw' ? 'Brush Settings' : 'Eraser Settings'}
</Label>
{drawMode === 'draw' && (
<>
<div>
<Label>Brush Effect</Label>
<Select value={brushEffect} onValueChange={(value) => setBrushEffect(value as BrushEffect)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<div className="flex items-center gap-2">
<PencilSimple size={16} />
Solid
</div>
</SelectItem>
<SelectItem value="gradient">
<div className="flex items-center gap-2">
<Gradient size={16} />
Gradient
</div>
</SelectItem>
<SelectItem value="spray">
<div className="flex items-center gap-2">
<Drop size={16} />
Spray Paint
</div>
</SelectItem>
<SelectItem value="glow">
<div className="flex items-center gap-2">
<Sparkle size={16} />
Glow
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Brush Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={brushColor}
onChange={(e) => setBrushColor(e.target.value)}
className="w-20 h-10"
/>
<Input
value={brushColor}
onChange={(e) => setBrushColor(e.target.value)}
placeholder="#ffffff"
/>
</div>
</div>
{brushEffect === 'gradient' && (
<div>
<Label>Gradient End Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={gradientColor}
onChange={(e) => setGradientColor(e.target.value)}
className="w-20 h-10"
/>
<Input
value={gradientColor}
onChange={(e) => setGradientColor(e.target.value)}
placeholder="#ff00ff"
/>
</div>
</div>
)}
{brushEffect === 'glow' && (
<div>
<Label>Glow Intensity: {glowIntensity}px</Label>
<Slider
value={[glowIntensity]}
onValueChange={([value]) => setGlowIntensity(value)}
min={1}
max={30}
step={1}
/>
</div>
)}
</>
)}
<div>
<Label>
{drawMode === 'draw' ? 'Brush' : 'Eraser'} Size: {brushSize}px
</Label>
<Slider
value={[brushSize]}
onValueChange={([value]) => setBrushSize(value)}
min={1}
max={20}
step={1}
/>
</div>
</div>
</>
)}
<Separator />
<div>
<Label className="mb-3 block">
Elements ({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') {
setSelectedElementId(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">{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={(e) => {
e.stopPropagation()
handleDeleteElement(element.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
{activeDesign.elements.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No elements yet. Add some or start drawing!
</p>
)}
</div>
</ScrollArea>
</div>
{selectedElement && drawMode === 'select' && (
<>
<Separator />
<div className="space-y-4">
<Label className="text-base font-semibold">Edit Element</Label>
{selectedElement.type === 'freehand' && (
<>
<div>
<Label>Brush Effect</Label>
<Select
value={selectedElement.brushEffect || 'solid'}
onValueChange={(value) => handleUpdateElement({ brushEffect: value as BrushEffect })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<div className="flex items-center gap-2">
<PencilSimple size={16} />
Solid
</div>
</SelectItem>
<SelectItem value="gradient">
<div className="flex items-center gap-2">
<Gradient size={16} />
Gradient
</div>
</SelectItem>
<SelectItem value="spray">
<div className="flex items-center gap-2">
<Drop size={16} />
Spray Paint
</div>
</SelectItem>
<SelectItem value="glow">
<div className="flex items-center gap-2">
<Sparkle size={16} />
Glow
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Stroke Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={selectedElement.color}
onChange={(e) => handleUpdateElement({ color: e.target.value })}
className="w-20 h-10"
/>
<Input
value={selectedElement.color}
onChange={(e) => handleUpdateElement({ color: e.target.value })}
placeholder="#ffffff"
/>
</div>
</div>
{selectedElement.brushEffect === 'gradient' && (
<div>
<Label>Gradient End Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={selectedElement.gradientColor || '#ff00ff'}
onChange={(e) => handleUpdateElement({ gradientColor: e.target.value })}
className="w-20 h-10"
/>
<Input
value={selectedElement.gradientColor || '#ff00ff'}
onChange={(e) => handleUpdateElement({ gradientColor: e.target.value })}
placeholder="#ff00ff"
/>
</div>
</div>
)}
{selectedElement.brushEffect === 'glow' && (
<div>
<Label>Glow Intensity: {selectedElement.glowIntensity || 10}px</Label>
<Slider
value={[selectedElement.glowIntensity || 10]}
onValueChange={([value]) => handleUpdateElement({ glowIntensity: value })}
min={1}
max={30}
step={1}
/>
</div>
)}
<div>
<Label>Stroke Width: {selectedElement.strokeWidth || 3}px</Label>
<Slider
value={[selectedElement.strokeWidth || 3]}
onValueChange={([value]) => handleUpdateElement({ strokeWidth: value })}
min={1}
max={20}
step={1}
/>
</div>
</>
)}
{(selectedElement.type === 'text' || selectedElement.type === 'emoji') && (
<>
{selectedElement.type === 'text' && (
<div>
<Label>Text</Label>
<Input
value={selectedElement.text || ''}
onChange={(e) => handleUpdateElement({ text: e.target.value })}
placeholder="Enter text"
/>
</div>
)}
{selectedElement.type === 'emoji' && (
<div>
<Label>Emoji</Label>
<Input
value={selectedElement.emoji || ''}
onChange={(e) => handleUpdateElement({ emoji: e.target.value })}
placeholder="😀"
/>
</div>
)}
<div>
<Label>Font Size: {selectedElement.fontSize}px</Label>
<Slider
value={[selectedElement.fontSize || 32]}
onValueChange={([value]) => handleUpdateElement({ fontSize: value })}
min={12}
max={200}
step={1}
/>
</div>
{selectedElement.type === 'text' && (
<div>
<Label>Font Weight</Label>
<Select
value={selectedElement.fontWeight || 'bold'}
onValueChange={(value) => handleUpdateElement({ fontWeight: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bold">Bold</SelectItem>
<SelectItem value="lighter">Light</SelectItem>
</SelectContent>
</Select>
</div>
)}
</>
)}
{selectedElement.type !== 'text' && selectedElement.type !== 'emoji' && selectedElement.type !== 'freehand' && (
<>
<div>
<Label>Width: {selectedElement.width}px</Label>
<Slider
value={[selectedElement.width]}
onValueChange={([value]) => handleUpdateElement({ width: value })}
min={10}
max={activeDesign.size}
step={1}
/>
</div>
<div>
<Label>Height: {selectedElement.height}px</Label>
<Slider
value={[selectedElement.height]}
onValueChange={([value]) => handleUpdateElement({ height: value })}
min={10}
max={activeDesign.size}
step={1}
/>
</div>
</>
)}
{selectedElement.type !== 'freehand' && (
<>
<div>
<Label>X Position: {selectedElement.x}px</Label>
<Slider
value={[selectedElement.x]}
onValueChange={([value]) => handleUpdateElement({ x: value })}
min={0}
max={activeDesign.size}
step={1}
/>
</div>
<div>
<Label>Y Position: {selectedElement.y}px</Label>
<Slider
value={[selectedElement.y]}
onValueChange={([value]) => handleUpdateElement({ y: value })}
min={0}
max={activeDesign.size}
step={1}
/>
</div>
<div>
<Label>Rotation: {selectedElement.rotation}°</Label>
<Slider
value={[selectedElement.rotation]}
onValueChange={([value]) => handleUpdateElement({ rotation: value })}
min={0}
max={360}
step={1}
/>
</div>
</>
)}
{selectedElement.type !== 'freehand' && (
<div>
<Label>Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={selectedElement.color}
onChange={(e) => handleUpdateElement({ color: e.target.value })}
className="w-20 h-10"
/>
<Input
value={selectedElement.color}
onChange={(e) => handleUpdateElement({ color: e.target.value })}
placeholder="#ffffff"
/>
</div>
</div>
)}
</div>
</>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
)
}