# 图片编辑工具 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为小雨开发工具箱的实用工具中心新增一个"图片编辑"工具,支持裁剪、旋转、翻转、亮度/对比度/饱和度调节、滤镜、添加文字、涂鸦画笔等功能,纯前端 Canvas 实现。 **Architecture:** 采用单页面组件架构,使用 HTML5 Canvas API 进行图像处理。顶部为 Hero 横幅,中间为工具栏 + 画布编辑区 + 属性面板的三栏布局,底部为提示卡片。所有图像处理操作在 Canvas 上实时渲染,支持撤销/重做、导出下载。 **Tech Stack:** React 18, TypeScript, Vite, Ant Design, HTML5 Canvas API --- ## 文件结构 | 文件 | 操作 | 说明 | |------|------|------| | `src/pages/ImageEditor/ImageEditor.tsx` | 创建 | 图片编辑主页面组件 | | `src/pages/ImageEditor/ImageEditor.css` | 创建 | 图片编辑样式 | | `src/pages/Utility/Utility.tsx` | 修改 | 在 `utilityTools` 数组和 `routeMap` 中注册新工具 | | `src/App.tsx` | 修改 | 添加 `/utility/image-editor` 路由 | --- ## 设计规范 - **页面结构:** Hero 横幅(渐变背景 `#f59e0b -> #ef4444` + 浮动粒子动画 + `EditOutlined` 图标 + 标题"图片编辑")+ 主内容区(max-width: 1200px, margin: -30px auto 0) - **三栏布局:** 左侧工具栏(图标按钮)+ 中间画布区(Canvas + 上传/下载按钮)+ 右侧属性面板(滑块/输入框) - **返回按钮:** 使用 `ArrowLeftOutlined` 图标,className 为 `ie-back-btn`,点击返回 `/utility` - **使用记录:** 调用 `useRecordHistory` hook,参数包含 `type: "tool"`, `title: "图片编辑"`, `description: "在线图片编辑,支持裁剪、滤镜、调节等功能"`, `category: "设计"`, `link: "/utility/image-editor"` - **响应式:** 移动端(max-width: 768px)时属性面板折叠到底部 --- ## Task 1: 创建图片编辑主页面 **Files:** - Create: `src/pages/ImageEditor/ImageEditor.tsx` - Create: `src/pages/ImageEditor/ImageEditor.css` ### 功能需求 #### 1. 图片上传 - 支持点击上传和拖拽上传 - 支持 JPG、PNG、WebP 格式 - 上传后图片渲染到 Canvas 上 #### 2. 基础变换 - **旋转:** 左转 90° / 右转 90° / 旋转 180° - **翻转:** 水平翻转 / 垂直翻转 - **裁剪:** 预设比例(1:1, 4:3, 16:9, 自由)+ 裁剪框拖拽调整 #### 3. 色彩调节 - **亮度:** -100 到 +100 滑块 - **对比度:** -100 到 +100 滑块 - **饱和度:** -100 到 +100 滑块 - **灰度:** 0 到 100 滑块 - **模糊:** 0 到 20px 滑块 #### 4. 滤镜预设 - 原图、黑白、复古、冷色调、暖色调、高对比、柔光、锐化 #### 5. 文字添加 - 输入文字内容 - 选择字体大小、颜色、位置(点击画布放置) #### 6. 涂鸦画笔 - 画笔大小调节 - 画笔颜色选择 - 橡皮擦模式 #### 7. 撤销/重做 - 维护操作历史栈 - 最多 20 步历史记录 #### 8. 导出下载 - 下载编辑后的图片(保持原格式或选择 PNG/JPG) - 支持调节导出质量 ### 组件状态设计 ```ts interface EditorState { image: HTMLImageElement | null; originalImage: HTMLImageElement | null; canvas: HTMLCanvasElement | null; ctx: CanvasRenderingContext2D | null; brightness: number; contrast: number; saturation: number; grayscale: number; blur: number; rotation: number; flipH: boolean; flipV: boolean; filter: string; cropMode: boolean; cropRect: { x: number; y: number; width: number; height: number } | null; texts: TextItem[]; brushMode: boolean; brushSize: number; brushColor: string; isDrawing: boolean; history: ImageData[]; historyIndex: number; } interface TextItem { id: string; text: string; x: number; y: number; fontSize: number; color: string; } ``` - [ ] **Step 1: 创建 ImageEditor.tsx 主组件** ```tsx import "./ImageEditor.css"; import React, { useState, useRef, useCallback, useEffect } from "react"; import { Button, Slider, Input, ColorPicker, Radio, message, Tooltip, Select, } from "antd"; import { EditOutlined, ArrowLeftOutlined, UploadOutlined, DownloadOutlined, RotateLeftOutlined, RotateRightOutlined, SwapOutlined, ColumnWidthOutlined, ColumnHeightOutlined, BgColorsOutlined, FontSizeOutlined, HighlightOutlined, UndoOutlined, RedoOutlined, ScissorOutlined, ReloadOutlined, } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; import { useRecordHistory } from "@/hooks/useRecordHistory"; interface TextItem { id: string; text: string; x: number; y: number; fontSize: number; color: string; } const filterPresets: Record = { none: "none", grayscale: "grayscale(100%)", sepia: "sepia(100%)", invert: "invert(100%)", brightness: "brightness(120%)", contrast: "contrast(150%)", blur: "blur(2px)", warm: "sepia(30%) saturate(140%) hue-rotate(-10deg)", cool: "saturate(120%) hue-rotate(10deg)", vintage: "sepia(50%) contrast(120%) brightness(90%)", }; const ImageEditor: React.FC = () => { const navigate = useNavigate(); const canvasRef = useRef(null); const fileInputRef = useRef(null); useRecordHistory({ type: "tool", title: "图片编辑", description: "在线图片编辑,支持裁剪、滤镜、调节等功能", category: "设计", link: "/utility/image-editor", }); const [image, setImage] = useState(null); const [brightness, setBrightness] = useState(0); const [contrast, setContrast] = useState(0); const [saturation, setSaturation] = useState(0); const [grayscale, setGrayscale] = useState(0); const [blur, setBlur] = useState(0); const [rotation, setRotation] = useState(0); const [flipH, setFlipH] = useState(false); const [flipV, setFlipV] = useState(false); const [filterPreset, setFilterPreset] = useState("none"); const [activeTool, setActiveTool] = useState("adjust"); const [texts, setTexts] = useState([]); const [textInput, setTextInput] = useState(""); const [textSize, setTextSize] = useState(24); const [textColor, setTextColor] = useState("#ffffff"); const [brushSize, setBrushSize] = useState(5); const [brushColor, setBrushColor] = useState("#ff0000"); const [isDrawing, setIsDrawing] = useState(false); const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const [exportFormat, setExportFormat] = useState("image/png"); const [exportQuality, setExportQuality] = useState(90); const saveHistory = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); setHistory((prev) => { const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(imageData); if (newHistory.length > 20) newHistory.shift(); return newHistory; }); setHistoryIndex((prev) => Math.min(prev + 1, 19)); }, [historyIndex]); const undo = () => { if (historyIndex <= 0) return; const newIndex = historyIndex - 1; setHistoryIndex(newIndex); const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.putImageData(history[newIndex], 0, 0); }; const redo = () => { if (historyIndex >= history.length - 1) return; const newIndex = historyIndex + 1; setHistoryIndex(newIndex); const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.putImageData(history[newIndex], 0, 0); }; const applyCanvasTransform = useCallback(() => { const canvas = canvasRef.current; if (!canvas || !image) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const isRotated90 = rotation % 180 !== 0; const imgWidth = image.naturalWidth; const imgHeight = image.naturalHeight; canvas.width = isRotated90 ? imgHeight : imgWidth; canvas.height = isRotated90 ? imgWidth : imgHeight; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate((rotation * Math.PI) / 180); ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1); const filterStr = [ filterPreset !== "none" ? filterPresets[filterPreset] : "", `brightness(${100 + brightness}%)`, `contrast(${100 + contrast}%)`, `saturate(${100 + saturation}%)`, `grayscale(${grayscale}%)`, blur > 0 ? `blur(${blur}px)` : "", ] .filter(Boolean) .join(" "); ctx.filter = filterStr || "none"; ctx.drawImage( image, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight ); ctx.restore(); // Draw texts texts.forEach((t) => { ctx.font = `bold ${t.fontSize}px sans-serif`; ctx.fillStyle = t.color; ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 2; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); }); }, [image, brightness, contrast, saturation, grayscale, blur, rotation, flipH, flipV, filterPreset, texts]); useEffect(() => { applyCanvasTransform(); }, [applyCanvasTransform]); const handleFileUpload = (file: File) => { if (!file.type.startsWith("image/")) { message.error("请上传图片文件"); return; } const img = new Image(); img.onload = () => { setImage(img); setRotation(0); setFlipH(false); setFlipV(false); setBrightness(0); setContrast(0); setSaturation(0); setGrayscale(0); setBlur(0); setFilterPreset("none"); setTexts([]); setHistory([]); setHistoryIndex(-1); const canvas = canvasRef.current; if (canvas) { canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d"); if (ctx) { ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); setHistory([imageData]); setHistoryIndex(0); } } }; img.src = URL.createObjectURL(file); }; const handleFileInput = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) handleFileUpload(file); e.target.value = ""; }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) handleFileUpload(file); }; const handleCanvasClick = (e: React.MouseEvent) => { if (activeTool !== "text" || !textInput.trim()) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; const newText: TextItem = { id: Math.random().toString(36).substring(2, 9), text: textInput, x, y, fontSize: textSize, color: textColor, }; setTexts((prev) => [...prev, newText]); saveHistory(); }; const handleMouseDown = (e: React.MouseEvent) => { if (activeTool !== "brush") return; setIsDrawing(true); const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; ctx.beginPath(); ctx.moveTo(x, y); ctx.strokeStyle = brushColor; ctx.lineWidth = brushSize; ctx.lineCap = "round"; ctx.lineJoin = "round"; }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDrawing || activeTool !== "brush") return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; ctx.lineTo(x, y); ctx.stroke(); }; const handleMouseUp = () => { if (isDrawing) { setIsDrawing(false); saveHistory(); } }; const handleDownload = () => { const canvas = canvasRef.current; if (!canvas) return; const link = document.createElement("a"); link.download = `edited_image.${exportFormat === "image/png" ? "png" : "jpg"}`; link.href = canvas.toDataURL(exportFormat, exportQuality / 100); link.click(); message.success("图片已下载"); }; const resetAll = () => { setBrightness(0); setContrast(0); setSaturation(0); setGrayscale(0); setBlur(0); setRotation(0); setFlipH(false); setFlipV(false); setFilterPreset("none"); setTexts([]); }; const rotateLeft = () => setRotation((r) => r - 90); const rotateRight = () => setRotation((r) => r + 90); const toolButtons = [ { key: "adjust", icon: , label: "调节" }, { key: "filter", icon: , label: "滤镜" }, { key: "text", icon: , label: "文字" }, { key: "brush", icon: , label: "画笔" }, ]; return (
{Array.from({ length: 6 }).map((_, i) => (
))}

图片编辑

{/* 左侧工具栏 */}
{toolButtons.map((tool) => ( ))}
{/* 中间画布区 */}
{!image ? (
fileInputRef.current?.click()} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} >

点击或拖拽图片到此处

支持 JPG、PNG、WebP

) : (
{canvasRef.current?.width} × {canvasRef.current?.height} px
)}
{/* 右侧属性面板 */}
{activeTool === "adjust" && (

色彩调节

)} {activeTool === "filter" && (

滤镜预设

{Object.entries(filterPresets).map(([key, filter]) => ( ))}
)} {activeTool === "text" && (

添加文字

setTextInput(e.target.value)} rows={3} disabled={!image} />
setTextColor(c.toHexString())} disabled={!image} />

点击画布放置文字

{texts.length > 0 && (
{texts.map((t, idx) => (
{t.text}
))}
)}
)} {activeTool === "brush" && (

画笔工具

setBrushColor(c.toHexString())} disabled={!image} />

在画布上按住鼠标拖拽绘制

)}

导出设置