50天50个小项目 (React19 + Tailwindcss V4) ✨ | DrawingApp(画板组件)

📅 我们继续 50 个小项目挑战!------ DrawingApp 组件

仓库地址:https://gitee.com/hhm-hhm/50days50projects.git

构建一个简单的在线画板应用。用户可以自由绘制图形、调节画笔粗细、选择颜色,并支持一键清空画布。

🌀 组件目标

  • 创建一个固定尺寸的画布区域
  • 支持鼠标点击拖动进行绘画
  • 提供按钮控制画笔粗细(+ / -)
  • 使用原生 <input type="color"> 选择画笔颜色
  • 提供"清空"按钮重置画布内容
  • 使用 TailwindCSS 快速构建现代 UI 界面

🔧 DrawingApp.tsx组件实现

TypeScript 复制代码
import React, { useRef, useEffect, useState } from 'react'

const DrawingApp: React.FC = () => {
    // Refs
    const canvasRef = useRef<HTMLCanvasElement>(null)
    const isDrawingRef = useRef(false) // 使用 ref 避免 draw 闭包问题
    const lastXRef = useRef(0)
    const lastYRef = useRef(0)
    const ctxRef = useRef<CanvasRenderingContext2D | null>(null)

    // State
    const [brushSize, setBrushSize] = useState<number>(5)
    const [brushColor, setBrushColor] = useState<string>('#000000')

    // 初始化画布
    useEffect(() => {
        const canvas = canvasRef.current
        if (!canvas) return

        // 设置画布尺寸为显示尺寸(避免模糊)
        const dpr = window.devicePixelRatio || 1
        const rect = canvas.getBoundingClientRect()
        canvas.width = rect.width * dpr
        canvas.height = rect.height * dpr

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

        // 缩放上下文以适配高清屏
        ctx.scale(dpr, dpr)
        ctx.lineCap = 'round'
        ctx.lineJoin = 'round'

        ctxRef.current = ctx
    }, [])

    // 开始绘制(仅左键)
    const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
        if (e.button !== 0) return // 只响应左键
        const canvas = canvasRef.current
        if (!canvas) return

        const rect = canvas.getBoundingClientRect()
        const x = e.clientX - rect.left
        const y = e.clientY - rect.top

        lastXRef.current = x
        lastYRef.current = y
        isDrawingRef.current = true
    }

    // 绘制中
    const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
        if (!isDrawingRef.current || !ctxRef.current) return

        const canvas = canvasRef.current
        if (!canvas) return

        const rect = canvas.getBoundingClientRect()
        const x = e.clientX - rect.left
        const y = e.clientY - rect.top

        const ctx = ctxRef.current
        ctx.beginPath()
        ctx.moveTo(lastXRef.current, lastYRef.current)
        ctx.lineTo(x, y)
        ctx.strokeStyle = brushColor
        ctx.lineWidth = brushSize
        ctx.stroke()

        lastXRef.current = x
        lastYRef.current = y
    }

    // 停止绘制
    const stopDrawing = () => {
        isDrawingRef.current = false
    }

    // 控制画笔大小
    const increaseBrushSize = () => {
        setBrushSize((prev) => Math.min(prev + 1, 50))
    }
    const decreaseBrushSize = () => {
        setBrushSize((prev) => Math.max(prev - 1, 1))
    }

    // 清空画布
    const clearCanvas = () => {
        const canvas = canvasRef.current
        const ctx = ctxRef.current
        if (!canvas || !ctx) return

        ctx.clearRect(
            0,
            0,
            canvas.width / (window.devicePixelRatio || 1),
            canvas.height / (window.devicePixelRatio || 1)
        )
    }

    return (
        <div className="flex min-h-screen items-center justify-center bg-gray-900">
            <div className="flex flex-col items-center">
                {/* 🎨 画板区域 */}
                <canvas
                    ref={canvasRef}
                    className="aspect-square w-[800px] border-2 border-gray-300 bg-white"
                    onMouseDown={startDrawing}
                    onMouseMove={draw}
                    onMouseUp={stopDrawing}
                    onMouseLeave={stopDrawing}
                    onContextMenu={(e) => e.preventDefault()} // 禁用右键菜单
                />

                {/* 🛠️ 工具栏 */}
                <div className="mt-4 flex w-[800px] items-center justify-between rounded-lg bg-gray-800 p-3">
                    {/* 粗细调节 */}
                    <div className="flex items-center">
                        <button
                            onClick={decreaseBrushSize}
                            className="rounded p-2 text-white hover:bg-gray-700"
                            disabled={brushSize <= 1}>
                            -
                        </button>
                        <span className="mx-3 text-white">{brushSize}</span>
                        <button
                            onClick={increaseBrushSize}
                            className="rounded p-2 text-white hover:bg-gray-700"
                            disabled={brushSize >= 50}>
                            +
                        </button>
                    </div>

                    {/* 🎨 颜色选择 */}
                    <input
                        type="color"
                        value={brushColor}
                        onChange={(e) => setBrushColor(e.target.value)}
                        className="h-10 w-10 cursor-pointer appearance-none rounded border-0 bg-transparent"
                    />

                    {/* 清空画布 */}
                    <button
                        onClick={clearCanvas}
                        className="rounded bg-red-600 p-2 text-white hover:bg-red-700">
                        清空
                    </button>
                </div>
            </div>
            <div className="fixed right-20 bottom-5 text-2xl text-red-500">CSDN@Hao_Harrision</div>
        </div>
    )
}

export default DrawingApp

🔍 关键技术说明

1. 使用 useRef 管理可变状态

  • isDrawing, lastX, lastY 使用 ref 而非 state,避免 draw 函数因闭包捕获旧值。
  • ctx 也用 ref 缓存,避免重复获取。

2. 高 DPI 屏幕适配(防模糊)

  • 获取 devicePixelRatio 并放大 canvas 尺寸;
  • 同时缩放绘图上下文(ctx.scale(dpr, dpr));
  • 清空时需除以 dpr 得到逻辑尺寸。

3. 坐标计算

  • 使用 getBoundingClientRect() 获取 canvas 位置;
  • clientX/Y - rect.left/top 得到相对于 canvas 的坐标。

4. 事件处理

  • onMouseDown / onMouseMove 等使用 React 事件系统;
  • onContextMenu 阻止默认右键菜单。

5. 无障碍与 UX

  • 按钮添加 disabled 状态(当画笔已达最小/最大);
  • 颜色选择器移除浏览器默认样式:appearance-none + border-0

💡 可选增强建议

功能 实现方式
移动端支持 添加 onTouchStart / onTouchMove 等事件
撤销功能 保存 canvas 快照到栈中
导出图片 使用 canvas.toDataURL()
自定义背景 clearCanvas 中填充背景色或图案

🎨 TailwindCSS 样式重点讲解

类名 作用
min-h-screen 设置最小高度为视口高度
items-center, justify-center Flexbox 居中对齐布局
bg-gray-900 设置深色背景
aspect-square 保持画布为正方形比例
w-[800px] 固定宽度为 800px
border-2, border-gray-300 边框样式
bg-white 画布背景色
rounded-lg, p-3 工具栏圆角与内边距
hover:bg-gray-700 按钮悬停变色
ext-white 白色文字
cursor-pointer 鼠标悬停变为手型
h-10, w-10 设置颜色选择器大小
[🎯 TailwindCSS 样式说明]

🦌 路由组件 + 常量定义

router/index.tsxchildren数组中添加子路由

TypeScript 复制代码
{
    path: '/',
    element: <App />,
    children: [
       ...
      {
                path: '/DrawingApp',
                lazy: () =>
                    import('@/projects/DrawingApp.tsx').then((mod) => ({
                        Component: mod.default,
                    })),
            },
    ],
 },

constants/index.tsx 添加组件预览常量

TypeScript 复制代码
import demo22Img from '@/assets/pic-demo/demo-22.png'
省略部分....
export const projectList: ProjectItem[] = [
    省略部分....
     {
        id: 22,
        title: 'DrawingApp',
        image: demo22Img,
        link: 'DrawingApp',
    },

🚀 小结

你可以进一步扩展此组件的功能,例如:

  • ✅ 支持保存画布内容为图片(canvas.toDataURL()
  • ✅ 添加撤销/重做功能(记录历史快照)
  • ✅ 支持触控设备(如 iPad 或触摸屏)
  • ✅ 封装为独立组件(支持 props 传入默认颜色或大小)

📅 明日预告: 我们将完成KineticLoader组件,一个很有意思的旋转加载动画。🚀

原文链接:https://blog.csdn.net/qq_44808710/article/details/149150719

每天造一个轮子,码力暴涨不是梦!🚀

相关推荐
dly_blog3 小时前
Vite 原理与 Vue 项目实践
前端·javascript·vue.js
仅此,3 小时前
前端接收了id字段,发送给后端就变了
java·前端·javascript·spring·typescript
Lovely Ruby3 小时前
[前端] 封装一下 echart 6,发布到 npm
前端·npm·node.js
BD_Marathon3 小时前
NPM_常见命令
前端·npm·node.js
绿鸳3 小时前
12.17面试题
前端
Huanzhi_Lin3 小时前
禁用谷歌/google/chrome浏览器更新
前端·chrome
咸鱼加辣3 小时前
【前端的crud】DOM 就是前端里的“数据库”
前端·数据库
kong79069283 小时前
环境搭建-运行前端工程(Nginx)
前端·nginx·前端工程
成都证图科技有限公司4 小时前
Bus Hound概述
前端