S SmartDocs
Série: React.js javascript 267 linhas · Atualizado 2026-04-02

Project_DrawingBoard.jsx

React.js/lessons/06_event_handling/projects/Project_DrawingBoard.jsx

/**
 * Project: 互動式畫板 — 事件處理練習
 *
 * 學習重點:
 * - 滑鼠事件(mouseDown, mouseUp, mouseMove, mouseLeave)
 * - 事件物件的屬性(clientX, clientY, currentTarget)
 * - getBoundingClientRect() 計算相對座標
 * - 事件處理函式的寫法
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState, useCallback } from 'react';

function DrawingBoard() {
  const [isDrawing, setIsDrawing] = useState(false);
  const [lines, setLines] = useState([]);
  const [currentLine, setCurrentLine] = useState([]);
  const [color, setColor] = useState('#4f46e5');
  const [brushSize, setBrushSize] = useState(4);
  const [tool, setTool] = useState('pen'); // 'pen' | 'eraser'
  const [history, setHistory] = useState([]);

  const colors = [
    '#0f172a', '#ef4444', '#f59e0b', '#22c55e',
    '#3b82f6', '#8b5cf6', '#ec4899', '#4f46e5',
  ];

  const getPosition = useCallback((e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    };
  }, []);

  const handleMouseDown = (e) => {
    setIsDrawing(true);
    const pos = getPosition(e);
    setCurrentLine([pos]);
  };

  const handleMouseMove = (e) => {
    if (!isDrawing) return;
    const pos = getPosition(e);
    setCurrentLine((prev) => [...prev, pos]);
  };

  const handleMouseUp = () => {
    if (!isDrawing) return;
    setIsDrawing(false);
    if (currentLine.length > 1) {
      const newLine = {
        points: currentLine,
        color: tool === 'eraser' ? '#ffffff' : color,
        size: tool === 'eraser' ? brushSize * 4 : brushSize,
      };
      setHistory((prev) => [...prev, lines]);
      setLines((prev) => [...prev, newLine]);
    }
    setCurrentLine([]);
  };

  const handleMouseLeave = () => {
    if (isDrawing) {
      handleMouseUp();
    }
  };

  const clearBoard = () => {
    setHistory((prev) => [...prev, lines]);
    setLines([]);
  };

  const undo = () => {
    if (history.length === 0) return;
    const prevState = history[history.length - 1];
    setLines(prevState);
    setHistory((prev) => prev.slice(0, -1));
  };

  const renderLine = (line, index) => {
    if (line.points.length < 2) return null;
    const pathData = line.points
      .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
      .join(' ');
    return (
      <path
        key={index}
        d={pathData}
        stroke={line.color}
        strokeWidth={line.size}
        strokeLinecap="round"
        strokeLinejoin="round"
        fill="none"
      />
    );
  };

  const renderCurrentLine = () => {
    if (currentLine.length < 2) return null;
    const pathData = currentLine
      .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
      .join(' ');
    return (
      <path
        d={pathData}
        stroke={tool === 'eraser' ? '#ffffff' : color}
        strokeWidth={tool === 'eraser' ? brushSize * 4 : brushSize}
        strokeLinecap="round"
        strokeLinejoin="round"
        fill="none"
      />
    );
  };

  const toolbarStyle = {
    display: 'flex',
    alignItems: 'center',
    gap: '16px',
    padding: '12px 16px',
    backgroundColor: '#f8fafc',
    borderRadius: '12px',
    marginBottom: '12px',
    flexWrap: 'wrap',
  };

  return (
    <div style={{
      maxWidth: '700px',
      margin: '32px auto',
      fontFamily: "'Segoe UI', sans-serif",
      padding: '0 16px',
    }}>
      <h1 style={{ textAlign: 'center', color: '#0f172a', marginBottom: '24px' }}>
        互動式畫板
      </h1>

      {/* 工具列 */}
      <div style={toolbarStyle}>
        {/* 工具選擇 */}
        <div style={{ display: 'flex', gap: '4px' }}>
          {[
            { key: 'pen', icon: '✏️', label: '畫筆' },
            { key: 'eraser', icon: '🧹', label: '橡皮擦' },
          ].map((t) => (
            <button
              key={t.key}
              onClick={() => setTool(t.key)}
              title={t.label}
              style={{
                padding: '6px 12px',
                borderRadius: '8px',
                border: 'none',
                backgroundColor: tool === t.key ? '#4f46e5' : 'white',
                color: tool === t.key ? 'white' : '#64748b',
                cursor: 'pointer',
                fontSize: '0.9rem',
              }}
            >
              {t.icon}
            </button>
          ))}
        </div>

        {/* 分隔線 */}
        <div style={{ width: '1px', height: '24px', backgroundColor: '#e2e8f0' }} />

        {/* 顏色選擇 */}
        <div style={{ display: 'flex', gap: '4px' }}>
          {colors.map((c) => (
            <div
              key={c}
              onClick={() => { setColor(c); setTool('pen'); }}
              style={{
                width: '24px',
                height: '24px',
                borderRadius: '50%',
                backgroundColor: c,
                cursor: 'pointer',
                border: color === c && tool === 'pen' ? '3px solid #94a3b8' : '2px solid transparent',
                boxSizing: 'border-box',
              }}
            />
          ))}
        </div>

        <div style={{ width: '1px', height: '24px', backgroundColor: '#e2e8f0' }} />

        {/* 筆刷大小 */}
        <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
          <span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>粗細</span>
          <input
            type="range"
            min="1"
            max="20"
            value={brushSize}
            onChange={(e) => setBrushSize(Number(e.target.value))}
            style={{ width: '80px', accentColor: '#4f46e5' }}
          />
          <span style={{ fontSize: '0.8rem', color: '#64748b', width: '24px' }}>{brushSize}</span>
        </div>

        <div style={{ width: '1px', height: '24px', backgroundColor: '#e2e8f0' }} />

        {/* 操作按鈕 */}
        <button
          onClick={undo}
          disabled={history.length === 0}
          style={{
            padding: '6px 12px',
            borderRadius: '8px',
            border: '1px solid #e2e8f0',
            backgroundColor: 'white',
            color: history.length === 0 ? '#cbd5e1' : '#64748b',
            cursor: history.length === 0 ? 'not-allowed' : 'pointer',
            fontSize: '0.85rem',
          }}
        >
          ↩ 復原
        </button>
        <button
          onClick={clearBoard}
          disabled={lines.length === 0}
          style={{
            padding: '6px 12px',
            borderRadius: '8px',
            border: '1px solid #fca5a5',
            backgroundColor: 'white',
            color: lines.length === 0 ? '#cbd5e1' : '#ef4444',
            cursor: lines.length === 0 ? 'not-allowed' : 'pointer',
            fontSize: '0.85rem',
          }}
        >
          🗑 清除
        </button>
      </div>

      {/* 畫布 */}
      <svg
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseLeave}
        style={{
          width: '100%',
          height: '450px',
          backgroundColor: 'white',
          borderRadius: '12px',
          border: '2px solid #e2e8f0',
          cursor: tool === 'eraser' ? 'cell' : 'crosshair',
          display: 'block',
          touchAction: 'none',
        }}
      >
        {lines.map(renderLine)}
        {renderCurrentLine()}
      </svg>

      <p style={{ textAlign: 'center', color: '#cbd5e1', fontSize: '0.8rem', marginTop: '8px' }}>
        按住滑鼠左鍵拖曳繪畫 · 線條數:{lines.length} · 雙擊任何色塊選色
      </p>
    </div>
  );
}

export default DrawingBoard;

Artigos relacionados