Skip to content

Commit

Permalink
Change doodle drawer to SVG output
Browse files Browse the repository at this point in the history
  • Loading branch information
floguo committed Dec 12, 2024
1 parent 78c6e7e commit 453f719
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 113 deletions.
115 changes: 113 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
Expand All @@ -34,6 +33,7 @@
"eslint-config-next": "15.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}
8 changes: 0 additions & 8 deletions postcss.config.mjs

This file was deleted.

188 changes: 115 additions & 73 deletions src/components/doodle-drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect } from 'react'
import React, { useState, useRef } from 'react'
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Eraser, Pencil } from 'lucide-react'
Expand All @@ -9,82 +9,96 @@ interface DoodleDrawerProps {
}

export const DoodleDrawer: React.FC<DoodleDrawerProps> = ({ onClose, onDoodleAdd }) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [color, setColor] = useState('#000000')
const [lineWidth, setLineWidth] = useState(5)
const [isErasing, setIsErasing] = useState(false)
const [paths, setPaths] = useState<string[]>([])
const [currentPath, setCurrentPath] = useState<string>('')
const pointsRef = useRef<{ x: number; y: number }[]>([])

const getCoordinates = (e: React.MouseEvent<SVGSVGElement>) => {
const svg = svgRef.current
if (!svg) return { x: 0, y: 0 }

const point = svg.createSVGPoint()
point.x = e.clientX
point.y = e.clientY
const transformedPoint = point.matrixTransform(svg.getScreenCTM()?.inverse())

return {
x: transformedPoint.x,
y: transformedPoint.y
}
}

useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return

const ctx = canvas.getContext('2d')
if (!ctx) return

ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
}, [])

const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
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 startDrawing = (e: React.MouseEvent<SVGSVGElement>) => {
const point = getCoordinates(e)
pointsRef.current = [point]
setCurrentPath(`M ${point.x} ${point.y}`)
setIsDrawing(true)
}

const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
const draw = (e: React.MouseEvent<SVGSVGElement>) => {
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.strokeStyle = isErasing ? '#ffffff' : color
ctx.lineWidth = isErasing ? 20 : lineWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
const point = getCoordinates(e)
pointsRef.current.push(point)

if (pointsRef.current.length > 3) {
const points = pointsRef.current
const lastPoint = points[points.length - 1]
const controlPoint = points[points.length - 2]
const endPoint = {
x: (controlPoint.x + lastPoint.x) / 2,
y: (controlPoint.y + lastPoint.y) / 2
}

setCurrentPath(prev =>
`${prev} Q ${controlPoint.x} ${controlPoint.y}, ${endPoint.x} ${endPoint.y}`
)
}
}

const stopDrawing = () => {
if (currentPath) {
setPaths(prev => [...prev, currentPath])
setCurrentPath('')
}
setIsDrawing(false)
pointsRef.current = []
}

const clearCanvas = () => {
const canvas = canvasRef.current
if (!canvas) return

const ctx = canvas.getContext('2d')
if (!ctx) return

ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
setPaths([])
setCurrentPath('')
}

const saveDoodle = () => {
const canvas = canvasRef.current
if (!canvas) return
const svg = svgRef.current
if (!svg) return

const doodleUrl = canvas.toDataURL('image/png')
onDoodleAdd(doodleUrl)
onClose()
// Create a temporary canvas to convert SVG to PNG
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return

const svgData = new XMLSerializer().serializeToString(svg)
const img = new Image()

img.onload = () => {
canvas.width = svg.clientWidth
canvas.height = svg.clientHeight
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
const doodleUrl = canvas.toDataURL('image/png')
onDoodleAdd(doodleUrl)
onClose()
}

img.src = 'data:image/svg+xml;base64,' + btoa(svgData)
}

return (
Expand All @@ -94,23 +108,51 @@ export const DoodleDrawer: React.FC<DoodleDrawerProps> = ({ onClose, onDoodleAdd
<DialogTitle>Draw a Doodle</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4">
<canvas
ref={canvasRef}
width={400}
height={400}
className="border border-gray-300 rounded-md cursor-crosshair"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
<div className="w-full aspect-[4/3] bg-white rounded-md border-2 border-stone-200">
<svg
ref={svgRef}
className="w-full h-full cursor-crosshair"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
>
{paths.map((path, i) => (
<path
key={i}
d={path}
stroke={isErasing ? 'white' : color}
strokeWidth={lineWidth}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
{currentPath && (
<path
d={currentPath}
stroke={isErasing ? 'white' : color}
strokeWidth={lineWidth}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</div>
<div className="flex gap-2">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-10 h-10"
/>
<div className="relative w-10 h-10">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="absolute inset-0 w-full h-full rounded-full cursor-pointer opacity-0"
/>
<div
className="w-10 h-10 rounded-full border-2 border-stone-200"
style={{ backgroundColor: color }}
/>
</div>
<input
type="range"
min="1"
Expand All @@ -124,7 +166,7 @@ export const DoodleDrawer: React.FC<DoodleDrawerProps> = ({ onClose, onDoodleAdd
size="icon"
onClick={() => setIsErasing(!isErasing)}
>
{isErasing ? <Eraser className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{isErasing ? <Pencil className="h-4 w-4" /> : <Eraser className="h-4 w-4" />}
</Button>
<Button onClick={clearCanvas} variant="outline">
Clear
Expand Down
21 changes: 8 additions & 13 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center rounded-full font-mono transition-all duration-200 active:scale-95",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
default: "h-10 px-4 py-2",
sm: "h-8 rounded-full px-3 text-sm",
lg: "h-12 rounded-full px-8 text-lg",
icon: "h-9 w-9",
},
},
Expand Down
14 changes: 0 additions & 14 deletions tailwind.config.js

This file was deleted.

30 changes: 29 additions & 1 deletion tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,35 @@ export default {
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
},
},
},
plugins: [],
} satisfies Config;

0 comments on commit 453f719

Please sign in to comment.