#画图工具
画图工具
#整体架构
页面采用左右分栏布局,左侧是深色主题的工具栏,右侧是白色的画布区域。整体设计风格简洁专业,配色采用深蓝色系搭配亮色强调,视觉效果清晰舒适。
#核心功能模块
#1. 历史记录管理(左上角)
- 撤销/重回功能:支持快捷键Ctrl+Z和Ctrl+Y
- 历史栈限制:最多保存30步操作记录,优化内存使用
- 按钮状态联动:无可撤销/重回操作时按钮自动禁用
#2. 绘图工具集(丰富全面)
基础工具:
- 画笔:自由手绘,支持颜色和粗细调节
- 橡皮擦:模拟白色擦除效果
- 油漆桶:实现泛洪填充算法,可填充封闭区域
- 吸管取色:点击画布任意位置提取颜色,并自动切换回画笔
形状工具:
- 直线、箭头、矩形、圆形、三角形
- 多边形:可设置边数(3-64)
- 多角星:可设置角数,支持内外半径自动计算
#3. 参数调节面板
- 颜色选择器:实时预览和调整
- 线条粗细:1-50像素可调
- 旋转角度:0-365度,应用于多边形和多角星
- 边/角数调节:3-64范围,动态更新多边形和星星的顶点数
#4. 文件操作
- 清空画布:重置为白色背景
- 下载图片:将画布保存为PNG格式
- 打开本地图片:支持上传并自适应画布尺寸显示
#交互特性
#智能绘图机制
- 实时预览:绘制形状时显示橡皮筋效果,松手后固定
- 快照技术:使用
snapshot保存画布状态,实现形状的动态拉伸预览 - 性能优化:采用栈结构管理历史记录,避免内存溢出
#键盘快捷键
Ctrl+Z:撤销Ctrl+Y:重回
#工具状态联动
- 当前激活的工具高亮显示
- 吸管取色后自动切回画笔,优化工作流程
- 参数滑块实时显示当前值
#技术亮点
- 泛洪填充算法:实现了四向连通填充,可处理复杂封闭区域
- 几何绘图引擎:支持多边形和星形的参数化绘制,包含旋转角度计算
- 箭头绘制算法:基于角度计算箭头头部,保持方向一致性
- 历史记录优化:限制最大步数,自动移除最早记录
- 响应式画布:自动适应容器大小,保持绘图区域最佳显示
#用户体验细节
- 画布光标为十字准星,精确绘图
- 工具按钮分组清晰,减少认知负担
- 参数调节滑块带有数值显示
- 颜色选择器与取色工具无缝配合
- 文件上传采用隐藏input,界面简洁
这个画图工具适合从简单涂鸦到复杂几何绘制的各种场景,既可作为教学演示,也可用于日常快速绘图需求。
#源码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>网页绘图板</title>
<link rel="canonical" href="https://xplanc.org/primers/document/zh/03.HTML/90.实践案例/AI.画图工具.md"/>
<style>
:root {
--panel-bg: #2c3e50;
--btn-bg: #34495e;
--accent: #3498db;
--text: #ecf0f1;
}
body { margin: 0; display: flex; font-family: -apple-system, sans-serif; background: #7f8c8d; overflow: hidden; }
/* 侧边栏样式 */
.toolbar {
width: 260px; background: var(--panel-bg); color: var(--text);
padding: 15px; display: flex; flex-direction: column; gap: 12px;
box-shadow: 2px 0 10px rgba(0,0,0,0.3); z-index: 10; overflow-y: auto;
}
h4 { margin: 8px 0; font-size: 12px; color: #95a5a6; border-bottom: 1px solid #5d6d7e; padding-bottom: 4px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
button {
background: var(--btn-bg); border: none; color: white; padding: 8px;
border-radius: 4px; cursor: pointer; transition: 0.2s; font-size: 13px;
}
button:hover { background: #455a64; }
button.active { background: var(--accent); box-shadow: inset 0 0 5px rgba(0,0,0,0.5); }
button:disabled { opacity: 0.3; cursor: not-allowed; }
.control-item { display: flex; flex-direction: column; gap: 5px; }
input[type="color"] { width: 100%; height: 35px; border: none; background: none; cursor: pointer; }
/* 画布区域 */
.canvas-container { flex-grow: 1; display: flex; align-items: center; justify-content: center; position: relative; }
canvas { background: white; box-shadow: 0 0 25px rgba(0,0,0,0.3); cursor: crosshair; }
</style>
</head>
<body>
<aside class="toolbar">
<div class="grid">
<button id="undo" title="Ctrl+Z">撤销</button>
<button id="redo" title="Ctrl+Y">重回</button>
</div>
<h4>基础工具</h4>
<div class="grid">
<button id="brush" class="active">画笔</button>
<button id="eraser">橡皮擦</button>
<button id="fill">油漆桶</button>
<button id="picker">吸管取色</button>
</div>
<h4>形状工具</h4>
<div class="grid">
<button id="line">直线</button>
<button id="arrow">箭头</button>
<button id="rect">矩形</button>
<button id="circle">圆形</button>
<button id="triangle">三角形</button>
</div>
<div class="grid">
<button id="star">多角星</button>
<button id="polygon">多边形</button>
</div>
<h4>设置</h4>
<div class="control-item">
<label>当前颜色</label>
<input type="color" id="colorPicker" value="#00bfff">
</div>
<div class="control-item">
<label>线条粗细: <span id="sizeLab">5</span>px</label>
<input type="range" id="lineWidth" min="1" max="50" value="5">
</div>
<div class="control-item">
<label>旋转角度: <span id="rotateLab">0</span>°</label>
<input type="range" id="rotate" min="0" max="365" value="1">
</div>
<div class="control-item">
<label>边、角数: <span id="pointsLab">5</span></label>
<input type="range" id="points" min="3" max="64" value="5">
</div>
<h4>文件操作</h4>
<div class="grid">
<button id="clear">清空画布</button>
<button id="save" style="background:#27ae60">下载图片</button>
<button id="openBtn">打开本地</button>
</div>
<input type="file" id="upload" accept="image/*" style="display:none">
</aside>
<div class="canvas-container">
<canvas id="mainCanvas"></canvas>
</div>
<script>
/** --- 核心状态变量 --- **/
const canvas = document.getElementById('mainCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const colorPicker = document.getElementById('colorPicker');
const lineWidthInput = document.getElementById('lineWidth');
const rotateInput = document.getElementById('rotate');
const pointsInput = document.getElementById('points');
let isDrawing = false;
let currentTool = 'brush';
let startX, startY;
let snapshot; // 用于形状预览
let rotate = 0; // 用于旋转
let points = 5; // 多边形或多角星的点数
// 撤销/重回栈
let undoStack = [];
let redoStack = [];
const MAX_HISTORY = 30; // 限制最大存储步数,优化内存
/** --- 初始化 --- **/
function init() {
// 设置画布尺寸为容器大小
canvas.width = canvas.parentElement.clientWidth - 30;
canvas.height = canvas.parentElement.clientHeight - 30;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
saveHistory(); // 保存初始白色画布状态
}
/** --- 历史记录管理 --- **/
function saveHistory() {
// 动作发生后,清空重回栈
redoStack = [];
// 如果超过最大步数,删掉最早的记录
if (undoStack.length >= MAX_HISTORY) undoStack.shift();
undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
updateUndoButtons();
}
function undo() {
if (undoStack.length > 1) {
redoStack.push(undoStack.pop());
const prevState = undoStack[undoStack.length - 1];
ctx.putImageData(prevState, 0, 0);
updateUndoButtons();
}
}
function redo() {
if (redoStack.length > 0) {
const nextState = redoStack.pop();
undoStack.push(nextState);
ctx.putImageData(nextState, 0, 0);
updateUndoButtons();
}
}
function updateUndoButtons() {
document.getElementById('undo').disabled = undoStack.length <= 1;
document.getElementById('redo').disabled = redoStack.length === 0;
}
/** --- 几何计算绘图 --- **/
function drawShape(e) {
const x = e.offsetX, y = e.offsetY;
const w = x - startX, h = y - startY;
const r = Math.sqrt(w*w + h*h);
ctx.beginPath();
if (currentTool === 'line') {
ctx.moveTo(startX, startY); ctx.lineTo(x, y);
} else if (currentTool === 'rect') {
ctx.strokeRect(startX, startY, w, h);
} else if (currentTool === 'circle') {
ctx.arc(startX, startY, r, 0, Math.PI * 2);
} else if (currentTool === 'triangle') {
ctx.moveTo(startX, startY);
ctx.lineTo(x, y);
ctx.lineTo(startX - w, y);
ctx.closePath();
} else if (currentTool === 'polygon') {
drawPolygon(startX, startY, points, r);
} else if (currentTool === 'star') {
drawStar(startX, startY, points, r, r / 2.5);
} else if (currentTool === 'arrow') {
drawArrow(startX, startY, x, y);
}
ctx.stroke();
}
function drawPolygon(cx, cy, sides, radius) {
const angle = (Math.PI * 2) / sides;
const rotateAngle = (rotate - 90) * Math.PI / 180; // 添加旋转角度
const startX = cx + radius * Math.cos(rotateAngle);
const startY = cy + radius * Math.sin(rotateAngle);
ctx.moveTo(startX, startY);
for(let i=0; i<sides; i++) {
ctx.lineTo(cx + radius * Math.cos(rotateAngle + i * angle), cy + radius * Math.sin(rotateAngle + i * angle));
}
ctx.closePath();
}
function drawStar(cx, cy, spikes, outerR, innerR) {
// 1. 将角度转为弧度
const rotateAngle = rotate * Math.PI / 180;
// 2. 初始角度:原有的正上方(-90°) + 用户旋转量
let rot = (Math.PI / 2 * 3) + rotateAngle;
const step = Math.PI / spikes;
// 3. 重点:moveTo 必须指向旋转后的第一个外顶点
ctx.moveTo(
cx + Math.cos(rot) * outerR,
cy + Math.sin(rot) * outerR
);
for (let i = 0; i < spikes; i++) {
// 绘制外顶点
ctx.lineTo(cx + Math.cos(rot) * outerR, cy + Math.sin(rot) * outerR);
rot += step;
// 绘制内顶点
ctx.lineTo(cx + Math.cos(rot) * innerR, cy + Math.sin(rot) * innerR);
rot += step;
}
ctx.closePath();
}
function drawArrow(fx, fy, tx, ty) {
const head = 15, angle = Math.atan2(ty-fy, tx-fx);
ctx.moveTo(fx, fy); ctx.lineTo(tx, ty);
ctx.lineTo(tx - head * Math.cos(angle - Math.PI/6), ty - head * Math.sin(angle - Math.PI/6));
ctx.moveTo(tx, ty);
ctx.lineTo(tx - head * Math.cos(angle + Math.PI/6), ty - head * Math.sin(angle + Math.PI/6));
}
/** --- 事件监听 --- **/
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
startX = e.offsetX;
startY = e.offsetY;
ctx.strokeStyle = colorPicker.value;
ctx.lineWidth = lineWidthInput.value;
ctx.fillStyle = colorPicker.value;
rotate = rotateInput.value;
points = pointsInput.value;
// 获取当前快照用于预览
snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
if (currentTool === 'fill') {
floodFill(startX, startY, colorPicker.value);
saveHistory(); // 填充是即时完成的
} else if (currentTool === 'picker') {
const p = ctx.getImageData(startX, startY, 1, 1).data;
const hex = "#" + ((1 << 24) + (p[0] << 16) + (p[1] << 8) + p[2]).toString(16).slice(1);
colorPicker.value = hex;
document.getElementById('brush').click();
} else {
ctx.beginPath();
ctx.moveTo(startX, startY);
}
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
if (currentTool === 'brush') {
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
} else if (currentTool === 'eraser') {
ctx.strokeStyle = "#ffffff";
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
} else if (currentTool !== 'fill' && currentTool !== 'picker') {
ctx.putImageData(snapshot, 0, 0); // 还原上一步,显示形状拉伸动画
drawShape(e);
}
});
canvas.addEventListener('mouseup', () => {
if (isDrawing && currentTool !== 'fill' && currentTool !== 'picker') {
saveHistory(); // 只有在真正画完一笔/一个形状后才存入历史
}
isDrawing = false;
});
// 快捷键支持
window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') undo();
if (e.ctrlKey && e.key === 'y') redo();
});
// 按钮功能绑定
document.getElementById('undo').onclick = undo;
document.getElementById('redo').onclick = redo;
document.getElementById('clear').onclick = () => {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
saveHistory();
};
document.getElementById('save').onclick = () => {
const link = document.createElement('a');
link.download = 'art-export.png';
link.href = canvas.toDataURL();
link.click();
};
document.getElementById('openBtn').onclick = () => document.getElementById('upload').click();
document.getElementById('upload').onchange = (e) => {
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
saveHistory();
};
img.src = ev.target.result;
};
reader.readAsDataURL(e.target.files[0]);
};
// 工具切换 UI 逻辑
document.querySelectorAll('.toolbar button').forEach(btn => {
btn.addEventListener('click', () => {
if(['undo','redo','clear','save','openBtn'].includes(btn.id)) return;
document.querySelector('.active')?.classList.remove('active');
btn.classList.add('active');
currentTool = btn.id;
});
});
lineWidthInput.oninput = (e) => document.getElementById('sizeLab').textContent = e.target.value;
rotateInput.oninput = (e) => document.getElementById('rotateLab').textContent = e.target.value;
pointsInput.oninput = (e) => document.getElementById('pointsLab').textContent = e.target.value;
/** --- 油漆桶算法 (Flood Fill) --- **/
function floodFill(x, y, fillHex) {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imgData.data;
const idx = (y * canvas.width + x) * 4;
const targetR = data[idx], targetG = data[idx+1], targetB = data[idx+2];
const fillRGB = {
r: parseInt(fillHex.slice(1,3),16),
g: parseInt(fillHex.slice(3,5),16),
b: parseInt(fillHex.slice(5,7),16)
};
if(targetR === fillRGB.r && targetG === fillRGB.g && targetB === fillRGB.b) return;
const stack = [[x, y]];
while(stack.length) {
const [cx, cy] = stack.pop();
const currIdx = (cy * canvas.width + cx) * 4;
if(data[currIdx] === targetR && data[currIdx+1] === targetG && data[currIdx+2] === targetB) {
data[currIdx] = fillRGB.r; data[currIdx+1] = fillRGB.g; data[currIdx+2] = fillRGB.b; data[currIdx+3] = 255;
if(cx > 0) stack.push([cx-1, cy]);
if(cx < canvas.width-1) stack.push([cx+1, cy]);
if(cy > 0) stack.push([cx, cy-1]);
if(cy < canvas.height-1) stack.push([cx, cy+1]);
}
}
ctx.putImageData(imgData, 0, 0);
}
window.onload = init;
</script>
</body>
</html>