diff --git a/src/components/FaviconDesigner.tsx b/src/components/FaviconDesigner.tsx index bd8ce74..efc762f 100644 --- a/src/components/FaviconDesigner.tsx +++ b/src/components/FaviconDesigner.tsx @@ -4,7 +4,6 @@ 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' @@ -14,96 +13,17 @@ 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(), -} +import { BrushEffect, CanvasFilter, FaviconElement, FaviconDesign } from './FaviconDesigner/types' +import { PRESET_SIZES, ELEMENT_TYPES, DEFAULT_DESIGN } from './FaviconDesigner/constants' +import { drawCanvas } from './FaviconDesigner/canvasUtils' export function FaviconDesigner() { const [designs, setDesigns] = useKV('favicon-designs', [DEFAULT_DESIGN]) @@ -125,255 +45,11 @@ export function FaviconDesigner() { 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) + if (canvas) { + drawCanvas(canvas, activeDesign) } - } - - 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() - } + }, [activeDesign]) const handleAddElement = (type: FaviconElement['type']) => { const newElement: FaviconElement = { @@ -720,7 +396,10 @@ export function FaviconDesigner() { } setCurrentPath([]) - drawCanvas() + const canvas = canvasRef.current + if (canvas) { + drawCanvas(canvas, activeDesign) + } } const handleCanvasMouseLeave = () => { diff --git a/src/components/FaviconDesigner/canvasUtils.ts b/src/components/FaviconDesigner/canvasUtils.ts new file mode 100644 index 0000000..d9d95a7 --- /dev/null +++ b/src/components/FaviconDesigner/canvasUtils.ts @@ -0,0 +1,247 @@ +import { FaviconElement, FaviconDesign, CanvasFilter } from './types' + +export function 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), + } +} + +export function 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() +} + +export function 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() +} + +export function 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() +} + +export function applyCanvasFilter(ctx: CanvasRenderingContext2D, filter: CanvasFilter, intensity: number) { + const canvas = ctx.canvas + + 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 + } + } +} + +export function drawElement(ctx: CanvasRenderingContext2D, element: FaviconElement) { + 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() +} + +export function drawCanvas(canvas: HTMLCanvasElement, design: FaviconDesign) { + const ctx = canvas.getContext('2d') + if (!ctx) return + + const size = design.size + canvas.width = size + canvas.height = size + + ctx.fillStyle = design.backgroundColor + ctx.fillRect(0, 0, size, size) + + design.elements.forEach((element) => { + drawElement(ctx, element) + }) + + if (design.filter && design.filter !== 'none') { + applyCanvasFilter(ctx, design.filter, design.filterIntensity || 50) + } +} diff --git a/src/components/FaviconDesigner/constants.ts b/src/components/FaviconDesigner/constants.ts new file mode 100644 index 0000000..70c6c78 --- /dev/null +++ b/src/components/FaviconDesigner/constants.ts @@ -0,0 +1,48 @@ +import { + CircleNotch, + Square, + Triangle, + Star, + Heart, + Polygon, + TextT, + Image as ImageIcon, +} from '@phosphor-icons/react' +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 }, +] + +export 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(), +} diff --git a/src/components/FaviconDesigner/types.ts b/src/components/FaviconDesigner/types.ts new file mode 100644 index 0000000..0a06892 --- /dev/null +++ b/src/components/FaviconDesigner/types.ts @@ -0,0 +1,34 @@ +export type BrushEffect = 'solid' | 'gradient' | 'spray' | 'glow' +export type CanvasFilter = 'none' | 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'sepia' | 'invert' | 'saturate' | 'hue-rotate' | 'pixelate' + +export 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 +} + +export interface FaviconDesign { + id: string + name: string + size: number + backgroundColor: string + elements: FaviconElement[] + createdAt: number + updatedAt: number + filter?: CanvasFilter + filterIntensity?: number +}