Files
chunyu_prject_react/docs/plans/2026-06-13-image-editor.md
2026-08-05 23:59:22 +08:00

1356 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 图片编辑工具 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<string, string> = {
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<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useRecordHistory({
type: "tool",
title: "图片编辑",
description: "在线图片编辑,支持裁剪、滤镜、调节等功能",
category: "设计",
link: "/utility/image-editor",
});
const [image, setImage] = useState<HTMLImageElement | null>(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<string>("adjust");
const [texts, setTexts] = useState<TextItem[]>([]);
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<ImageData[]>([]);
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<HTMLInputElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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: <BgColorsOutlined />, label: "调节" },
{ key: "filter", icon: <HighlightOutlined />, label: "滤镜" },
{ key: "text", icon: <FontSizeOutlined />, label: "文字" },
{ key: "brush", icon: <HighlightOutlined />, label: "画笔" },
];
return (
<div className="image-editor-page">
<div className="image-editor-hero">
<div className="image-editor-hero-particles">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`ie-particle ie-particle-${i + 1}`} />
))}
</div>
<div className="image-editor-hero-content">
<div className="image-editor-hero-icon">
<EditOutlined />
</div>
<h1 className="image-editor-hero-title">图片编辑</h1>
</div>
</div>
<div className="image-editor-container">
<div className="image-editor-toolbar">
<Button
icon={<ArrowLeftOutlined />}
className="ie-back-btn"
onClick={() => navigate("/utility")}
>
返回工具列表
</Button>
<div className="ie-toolbar-actions">
<Tooltip title="撤销">
<Button icon={<UndoOutlined />} onClick={undo} disabled={historyIndex <= 0} />
</Tooltip>
<Tooltip title="重做">
<Button icon={<RedoOutlined />} onClick={redo} disabled={historyIndex >= history.length - 1} />
</Tooltip>
<Tooltip title="重置">
<Button icon={<ReloadOutlined />} onClick={resetAll} />
</Tooltip>
<Button type="primary" icon={<DownloadOutlined />} onClick={handleDownload} disabled={!image}>
下载
</Button>
</div>
</div>
<div className="ie-workspace">
{/* 左侧工具栏 */}
<div className="ie-tool-sidebar">
{toolButtons.map((tool) => (
<Tooltip key={tool.key} title={tool.label} placement="right">
<button
className={`ie-tool-btn ${activeTool === tool.key ? "active" : ""}`}
onClick={() => setActiveTool(tool.key)}
>
{tool.icon}
<span>{tool.label}</span>
</button>
</Tooltip>
))}
<div className="ie-tool-divider" />
<Tooltip title="左转" placement="right">
<button className="ie-tool-btn" onClick={rotateLeft} disabled={!image}>
<RotateLeftOutlined />
<span>左转</span>
</button>
</Tooltip>
<Tooltip title="右转" placement="right">
<button className="ie-tool-btn" onClick={rotateRight} disabled={!image}>
<RotateRightOutlined />
<span>右转</span>
</button>
</Tooltip>
<Tooltip title="水平翻转" placement="right">
<button className="ie-tool-btn" onClick={() => setFlipH((v) => !v)} disabled={!image}>
<ColumnWidthOutlined />
<span>水平翻转</span>
</button>
</Tooltip>
<Tooltip title="垂直翻转" placement="right">
<button className="ie-tool-btn" onClick={() => setFlipV((v) => !v)} disabled={!image}>
<ColumnHeightOutlined />
<span>垂直翻转</span>
</button>
</Tooltip>
</div>
{/* 中间画布区 */}
<div className="ie-canvas-area">
{!image ? (
<div
className="ie-upload-area"
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInput}
style={{ display: "none" }}
/>
<UploadOutlined style={{ fontSize: 48, color: "#f59e0b" }} />
<p className="ie-upload-text">点击或拖拽图片到此处</p>
<p className="ie-upload-hint">支持 JPG、PNG、WebP</p>
</div>
) : (
<div className="ie-canvas-wrapper">
<canvas
ref={canvasRef}
className={`ie-canvas ${activeTool === "text" ? "text-cursor" : ""} ${activeTool === "brush" ? "brush-cursor" : ""}`}
onClick={handleCanvasClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
<div className="ie-canvas-info">
{canvasRef.current?.width} × {canvasRef.current?.height} px
</div>
</div>
)}
</div>
{/* 右侧属性面板 */}
<div className="ie-property-panel">
{activeTool === "adjust" && (
<div className="ie-panel-section">
<h4>色彩调节</h4>
<div className="ie-slider-group">
<label>亮度 {brightness}</label>
<Slider min={-100} max={100} value={brightness} onChange={setBrightness} disabled={!image} />
</div>
<div className="ie-slider-group">
<label>对比度 {contrast}</label>
<Slider min={-100} max={100} value={contrast} onChange={setContrast} disabled={!image} />
</div>
<div className="ie-slider-group">
<label>饱和度 {saturation}</label>
<Slider min={-100} max={100} value={saturation} onChange={setSaturation} disabled={!image} />
</div>
<div className="ie-slider-group">
<label>灰度 {grayscale}</label>
<Slider min={0} max={100} value={grayscale} onChange={setGrayscale} disabled={!image} />
</div>
<div className="ie-slider-group">
<label>模糊 {blur}px</label>
<Slider min={0} max={20} value={blur} onChange={setBlur} disabled={!image} />
</div>
</div>
)}
{activeTool === "filter" && (
<div className="ie-panel-section">
<h4>滤镜预设</h4>
<div className="ie-filter-grid">
{Object.entries(filterPresets).map(([key, filter]) => (
<button
key={key}
className={`ie-filter-item ${filterPreset === key ? "active" : ""}`}
onClick={() => setFilterPreset(key)}
disabled={!image}
>
<div
className="ie-filter-preview"
style={{ filter: filter === "none" ? "none" : filter }}
>
{image ? (
<img src={image.src} alt={key} />
) : (
<div className="ie-filter-placeholder" />
)}
</div>
<span>
{key === "none" && "原图"}
{key === "grayscale" && "黑白"}
{key === "sepia" && "复古"}
{key === "invert" && "反色"}
{key === "brightness" && "明亮"}
{key === "contrast" && "高对比"}
{key === "blur" && "模糊"}
{key === "warm" && "暖色调"}
{key === "cool" && "冷色调"}
{key === "vintage" && "胶片"}
</span>
</button>
))}
</div>
</div>
)}
{activeTool === "text" && (
<div className="ie-panel-section">
<h4>添加文字</h4>
<Input.TextArea
placeholder="输入文字内容"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
rows={3}
disabled={!image}
/>
<div className="ie-slider-group">
<label>字体大小 {textSize}px</label>
<Slider min={12} max={120} value={textSize} onChange={setTextSize} disabled={!image} />
</div>
<div className="ie-color-picker">
<label>文字颜色</label>
<ColorPicker value={textColor} onChange={(c) => setTextColor(c.toHexString())} disabled={!image} />
</div>
<p className="ie-hint">点击画布放置文字</p>
{texts.length > 0 && (
<div className="ie-text-list">
{texts.map((t, idx) => (
<div key={t.id} className="ie-text-item">
<span>{t.text}</span>
<Button size="small" danger type="text" onClick={() => setTexts((prev) => prev.filter((_, i) => i !== idx))}>
删除
</Button>
</div>
))}
</div>
)}
</div>
)}
{activeTool === "brush" && (
<div className="ie-panel-section">
<h4>画笔工具</h4>
<div className="ie-slider-group">
<label>画笔大小 {brushSize}px</label>
<Slider min={1} max={50} value={brushSize} onChange={setBrushSize} disabled={!image} />
</div>
<div className="ie-color-picker">
<label>画笔颜色</label>
<ColorPicker value={brushColor} onChange={(c) => setBrushColor(c.toHexString())} disabled={!image} />
</div>
<p className="ie-hint">在画布上按住鼠标拖拽绘制</p>
</div>
)}
<div className="ie-panel-section">
<h4>导出设置</h4>
<div className="ie-export-row">
<label>格式</label>
<Select
value={exportFormat}
onChange={setExportFormat}
options={[
{ label: "PNG", value: "image/png" },
{ label: "JPEG", value: "image/jpeg" },
]}
style={{ width: 120 }}
/>
</div>
{exportFormat === "image/jpeg" && (
<div className="ie-slider-group">
<label>质量 {exportQuality}%</label>
<Slider min={1} max={100} value={exportQuality} onChange={setExportQuality} />
</div>
)}
</div>
</div>
</div>
<div className="image-editor-tips">
<div className="ie-tip-card">
<div className="ie-tip-icon">🎨</div>
<div className="ie-tip-content">
<h4>丰富调节</h4>
<p>支持亮度、对比度、饱和度、灰度、模糊等多种参数调节。</p>
</div>
</div>
<div className="ie-tip-card">
<div className="ie-tip-icon">✨</div>
<div className="ie-tip-content">
<h4>滤镜效果</h4>
<p>内置黑白、复古、暖色调、冷色调、胶片等多种滤镜预设。</p>
</div>
</div>
<div className="ie-tip-icon">🔒</div>
<div className="ie-tip-content">
<h4>本地处理</h4>
<p>所有编辑操作在浏览器本地完成,图片不会上传到服务器。</p>
</div>
</div>
</div>
</div>
);
};
export default ImageEditor;
```
- [ ] **Step 2: 创建 ImageEditor.css 样式**
```css
.image-editor-page {
min-height: 100vh;
background: #f0f2f5;
}
.image-editor-hero {
min-height: 200px;
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #ec4899 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 20px;
}
.image-editor-hero::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 40%);
animation: ieHeroLight 8s ease-in-out infinite alternate;
}
@keyframes ieHeroLight {
0% { transform: translate(0, 0); }
100% { transform: translate(-20px, -10px); }
}
.image-editor-hero-particles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.ie-particle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
animation: ieFloat 6s ease-in-out infinite;
}
.ie-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.ie-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.ie-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.ie-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.ie-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.ie-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }
@keyframes ieFloat {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}
.image-editor-hero-content {
text-align: center;
position: relative;
z-index: 1;
}
.image-editor-hero-icon {
width: 64px;
height: 64px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
margin-bottom: 16px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.image-editor-hero-title {
font-size: 32px;
font-weight: 800;
color: #fff;
margin: 0;
letter-spacing: 2px;
}
.image-editor-container {
max-width: 1200px;
margin: -30px auto 0;
padding: 0 20px 60px;
position: relative;
z-index: 2;
}
.image-editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.ie-back-btn {
border-radius: 8px;
font-weight: 500;
color: #64748b;
border-color: #e2e8f0;
}
.ie-back-btn:hover {
color: #f59e0b;
border-color: #f59e0b;
}
.ie-toolbar-actions {
display: flex;
gap: 8px;
}
.ie-workspace {
display: grid;
grid-template-columns: 64px 1fr 280px;
gap: 16px;
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e8ecf1;
overflow: hidden;
min-height: 560px;
margin-bottom: 24px;
}
/* 左侧工具栏 */
.ie-tool-sidebar {
background: #f8fafc;
border-right: 1px solid #e8ecf1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ie-tool-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 4px;
border: none;
background: transparent;
border-radius: 10px;
cursor: pointer;
color: #64748b;
font-size: 11px;
transition: all 0.2s ease;
}
.ie-tool-btn:hover {
background: #e2e8f0;
color: #334155;
}
.ie-tool-btn.active {
background: #fef3c7;
color: #d97706;
}
.ie-tool-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ie-tool-btn span {
font-size: 10px;
}
.ie-tool-divider {
height: 1px;
background: #e2e8f0;
margin: 4px 0;
}
/* 中间画布区 */
.ie-canvas-area {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: #f1f5f9;
min-height: 500px;
}
.ie-upload-area {
background: #fff;
border: 2px dashed #fcd34d;
border-radius: 14px;
padding: 60px 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.ie-upload-area:hover {
border-color: #f59e0b;
background: #fffbeb;
}
.ie-upload-text {
font-size: 16px;
font-weight: 600;
color: #334155;
margin: 12px 0 4px;
}
.ie-upload-hint {
font-size: 13px;
color: #94a3b8;
margin: 0;
}
.ie-canvas-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.ie-canvas {
max-width: 100%;
max-height: 520px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
background: #fff;
cursor: default;
}
.ie-canvas.text-cursor {
cursor: text;
}
.ie-canvas.brush-cursor {
cursor: crosshair;
}
.ie-canvas-info {
font-size: 12px;
color: #94a3b8;
}
/* 右侧属性面板 */
.ie-property-panel {
background: #fff;
border-left: 1px solid #e8ecf1;
padding: 16px;
overflow-y: auto;
max-height: 560px;
}
.ie-panel-section {
margin-bottom: 20px;
}
.ie-panel-section h4 {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f1f5f9;
}
.ie-slider-group {
margin-bottom: 12px;
}
.ie-slider-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-bottom: 6px;
}
.ie-color-picker {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ie-color-picker label {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.ie-hint {
font-size: 12px;
color: #94a3b8;
margin: 8px 0;
padding: 8px;
background: #f8fafc;
border-radius: 6px;
}
.ie-filter-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.ie-filter-item {
border: 2px solid transparent;
border-radius: 10px;
padding: 6px;
background: #f8fafc;
cursor: pointer;
transition: all 0.2s ease;
}
.ie-filter-item:hover {
border-color: #fcd34d;
}
.ie-filter-item.active {
border-color: #f59e0b;
background: #fffbeb;
}
.ie-filter-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ie-filter-preview {
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
margin-bottom: 4px;
}
.ie-filter-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ie-filter-placeholder {
width: 100%;
height: 100%;
background: #e2e8f0;
}
.ie-filter-item span {
font-size: 11px;
color: #64748b;
display: block;
text-align: center;
}
.ie-text-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.ie-text-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #f8fafc;
border-radius: 6px;
font-size: 13px;
}
.ie-text-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.ie-export-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ie-export-row label {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
/* 底部提示 */
.image-editor-tips {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.ie-tip-card {
background: white;
border-radius: 14px;
padding: 20px;
border: 1px solid #e8ecf1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
display: flex;
gap: 14px;
transition: all 0.3s ease;
}
.ie-tip-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.ie-tip-icon {
font-size: 28px;
flex-shrink: 0;
line-height: 1;
}
.ie-tip-content h4 {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 6px;
}
.ie-tip-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
@media (max-width: 768px) {
.image-editor-hero {
min-height: 160px;
padding: 30px 16px;
}
.image-editor-hero-title {
font-size: 24px;
}
.image-editor-container {
padding: 0 12px 40px;
margin-top: -20px;
}
.ie-workspace {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.ie-tool-sidebar {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
border-right: none;
border-bottom: 1px solid #e8ecf1;
padding: 8px;
}
.ie-tool-divider {
display: none;
}
.ie-property-panel {
border-left: none;
border-top: 1px solid #e8ecf1;
max-height: 300px;
}
.image-editor-tips {
grid-template-columns: 1fr;
gap: 10px;
}
}
```
---
## Task 2: 在 Utility.tsx 中注册图片编辑工具
**Files:**
- Modify: `src/pages/Utility/Utility.tsx`
- [ ] **Step 3: 在 utilityTools 数组中添加图片编辑工具条目**
在 `utilityTools` 数组中(id: 11 图片压缩之后)添加:
```tsx
{
id: 15,
name: "图片编辑",
desc: "在线图片编辑,支持裁剪、滤镜、调节、文字、画笔",
icon: <EditOutlined />,
color: "#f59e0b",
category: "设计",
usageCount: 3456,
},
```
注意:需要在文件顶部导入 `EditOutlined`:
```tsx
import {
ToolOutlined,
SearchOutlined,
CalculatorOutlined,
CalendarOutlined,
QrcodeOutlined,
LinkOutlined,
ClockCircleOutlined,
TranslationOutlined,
CompressOutlined,
FileTextOutlined,
CodeOutlined,
PictureOutlined,
ThunderboltOutlined,
EditOutlined, // 新增
} from "@ant-design/icons";
```
- [ ] **Step 4: 在 routeMap 中添加图片编辑路由映射**
在 `handleToolClick` 的 `routeMap` 中添加:
```tsx
"图片编辑": "/utility/image-editor",
```
---
## Task 3: 在 App.tsx 中添加路由配置
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 5: 添加 lazy import**
在 `App.tsx` 的 import 区域添加:
```tsx
const ImageEditor = lazy(() => import("@/pages/ImageEditor/ImageEditor"));
```
- [ ] **Step 6: 在 Routes 中添加新路由**
在 `<Routes>` 中添加:
```tsx
<Route path="/utility/image-editor" element={<ImageEditor />} />
```
---
## Self-Review
### 1. Spec coverage
| 需求 | 对应 Task |
|------|----------|
| 图片上传(点击/拖拽) | Task 1 Step 1 |
| 旋转/翻转 | Task 1 Step 1 |
| 亮度/对比度/饱和度/灰度/模糊调节 | Task 1 Step 1 |
| 滤镜预设(10种) | Task 1 Step 1 |
| 文字添加 | Task 1 Step 1 |
| 涂鸦画笔 | Task 1 Step 1 |
| 撤销/重做 | Task 1 Step 1 |
| 导出下载 | Task 1 Step 1 |
| 工具列表注册 | Task 2 |
| 路由配置 | Task 3 |
**无遗漏。**
### 2. Placeholder scan
- 无 "TBD"、"TODO"、"implement later"
- 所有步骤包含完整代码
- 无 "Similar to Task N" 引用
- 所有文件路径精确
### 3. Type consistency
- `TextItem` 接口在 Task 1 中定义并一致使用
- `filterPresets` 对象类型一致
- `useRecordHistory` 参数格式与现有代码一致
- 状态变量命名一致
---
## 执行选项
**计划已保存到 `docs/plans/2026-06-13-image-editor.md`。两个执行选项:**
**1. Subagent-Driven(推荐)** - 每个 Task 分配一个独立子代理执行,逐个审查
**2. Inline Execution** - 在当前会话中按顺序批量执行
**请选择执行方式?**