From fb1f55c7696bf393f6b3135ad87d8fad87f03ad6 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Fri, 12 Dec 2025 04:27:01 -0800 Subject: [PATCH] Add drawing pad app with eraser tool and canvas resize improvements --- frontend/src/config/apps.ts | 10 ++ frontend/src/pages/DrawingPadApp.tsx | 240 +++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 frontend/src/pages/DrawingPadApp.tsx diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 8930fd7..7821940 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -4,6 +4,7 @@ import React, { lazy } from 'react'; const VideoApp = lazy(() => import('../pages/VideoApp').then(module => ({ default: module.VideoApp }))); const SpeechSoundsApp = lazy(() => import('../pages/SpeechSoundsApp').then(module => ({ default: module.SpeechSoundsApp }))); const TicTacToeApp = lazy(() => import('../pages/TicTacToeApp').then(module => ({ default: module.TicTacToeApp }))); +const DrawingPadApp = lazy(() => import('../pages/DrawingPadApp').then(module => ({ default: module.DrawingPadApp }))); export type App = { id: string; @@ -42,5 +43,14 @@ export const APPS: App[] = [ link: '/tic-tac-toe', disabled: false, component: TicTacToeApp + }, + { + id: 'drawingpad', + name: 'Drawing Pad', + description: 'Draw and create your own artwork!', + cta: 'Start Drawing', + link: '/drawing-pad', + disabled: false, + component: DrawingPadApp } ]; diff --git a/frontend/src/pages/DrawingPadApp.tsx b/frontend/src/pages/DrawingPadApp.tsx new file mode 100644 index 0000000..dab0cce --- /dev/null +++ b/frontend/src/pages/DrawingPadApp.tsx @@ -0,0 +1,240 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; + +export function DrawingPadApp() { + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [color, setColor] = useState('#000000'); + const [brushSize, setBrushSize] = useState(5); + const [isEraser, setIsEraser] = useState(false); + + const startDrawing = useCallback((e: React.PointerEvent) => { + setIsDrawing(true); + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + ctx.beginPath(); + ctx.moveTo(x, y); + }, []); + + const draw = useCallback((e: React.PointerEvent) => { + if (!isDrawing) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + ctx.lineTo(x, y); + ctx.lineWidth = brushSize; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (isEraser) { + // Eraser mode: use destination-out to erase + ctx.globalCompositeOperation = 'destination-out'; + ctx.strokeStyle = 'rgba(0,0,0,1)'; // Color doesn't matter for erasing + } else { + // Drawing mode: normal composite operation + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = color; + } + + ctx.stroke(); + }, [isDrawing, color, brushSize, isEraser]); + + const stopDrawing = useCallback(() => { + setIsDrawing(false); + }, []); + + const clearCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.globalCompositeOperation = 'source-over'; + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Restore white background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }, []); + + const saveDrawing = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dataUrl = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.download = `drawing-${Date.now()}.png`; + link.href = dataUrl; + link.click(); + }, []); + + // Set canvas size + const setupCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const newWidth = rect.width; + const newHeight = rect.height; + + // Check if dimensions actually changed + if (canvas.width === newWidth && canvas.height === newHeight) { + return; // No resize needed + } + + // Save current canvas content before resizing (resizing clears the canvas) + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let savedImageData: ImageData | null = null; + if (canvas.width > 0 && canvas.height > 0) { + savedImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + } + + // Resize canvas (this clears it) + canvas.width = newWidth; + canvas.height = newHeight; + + // Restore white background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Restore saved content if we had any + if (savedImageData) { + // Draw the saved content back (it might be smaller, so center it or scale it) + ctx.putImageData(savedImageData, 0, 0); + } + }, []); + + // Set canvas size on mount and resize + const canvasRefCallback = useCallback((canvas: HTMLCanvasElement | null) => { + canvasRef.current = canvas; + if (canvas) { + setupCanvas(); + } + }, [setupCanvas]); + + useEffect(() => { + const handleResize = () => { + setupCanvas(); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [setupCanvas]); + + const colors = [ + '#000000', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', + '#FFA500', '#800080', '#FFC0CB', '#A52A2A', '#808080', '#FFFFFF' + ]; + + return ( +
+
+
+
+ +
+ {colors.map((c) => ( +
+ setColor(e.target.value)} + className="w-10 h-10 cursor-pointer" + /> +
+ +
+ + setBrushSize(Number(e.target.value))} + className="w-32" + /> + {brushSize}px +
+ +
+ + + +
+ + + + +
+ +
+ +
+
+
+ ); +}