概述
Lumin 博客在 探索 菜单下新增了 像素画 子页面,提供在线像素画绘制功能。支持画笔、橡皮擦、填充、取色器四种工具,5 种画布尺寸,18 色预设调色板,撤销/重做历史记录,网格线开关,PNG 导出,触摸屏适配和暗色模式。本文详解完整的实现方案。
架构设计
1hugo.toml → 菜单注册(探索 → 像素画,weight=19)
2content/pixelart/_index.md → 页面元数据(type=pixelart)
3layouts/pixelart/single.html → 单页模板(内联 CSS + JS)
4layouts/_default/baseof.html → noSidebar 列表添加 pixelart
5assets/css/main.css → :has(#pixelart-page) 全宽布局选择器
采用 Hugo 的 Content Type 模式:通过 type: pixelart 指定内容类型,Hugo 自动匹配 layouts/pixelart/single.html 模板渲染,无需后端 API,所有绘制逻辑在前端完成。
内容文件
content/pixelart/_index.md 定义页面元数据:
1---
2title: "像素画"
3slug: pixelart
4date: 2026-06-03T00:00:00+08:00
5draft: false
6type: pixelart
7description: "用像素创造你的艺术 — 在线像素画绘制工具"
8url: /pixelart/
9---
关键字段说明:
type: pixelart— 指定内容类型,匹配layouts/pixelart/目录下的模板url: /pixelart/— 固定 URL 路径
全宽布局集成
baseof.html 无侧边栏
在 layouts/_default/baseof.html 的 $noSidebar 变量中添加 pixelart:
1{{ $noSidebar := or (eq .Type "moments") (eq .Type "gallery") ... (eq .Type "pixelart") (eq .Type "error") }}
当 $noSidebar 为 true 时,页面走全宽布局分支,不渲染侧边栏和目录。
main.css 全宽选择器
在 assets/css/main.css 中添加 :has() 选择器,控制全宽页面的 padding-top:
1.main-wrapper:has(#pixelart-page),
2.main-wrapper:has(#tools-page),
3.main-wrapper:has(#explore-page),
4/* ... 其他全宽页面 */
5{
6 padding: calc(var(--header-height) + 80px) 0 0 !important;
7}
模板中通过 id="pixelart-page" 触发此选择器。
首页排除
在 layouts/index.html 中排除 pixelart 类型,避免出现在首页文章列表:
1{{ $allArticles := where (where site.RegularPages "Params.excludeFromList" "!=" true) "Type" "not in" (slice "moments" "amap" "pomodoro" "pixelart") }}
页面模板设计
模板 layouts/pixelart/single.html 采用内联 CSS + JS 的方式,与番茄钟、出行轨迹等页面风格统一。
Banner 区域
绿色渐变 Banner,高度固定 160px,与探索子菜单下其他页面风格一致:
1.pixelart-page-header{
2 text-align:center;
3 height:160px;
4 max-height:160px;
5 overflow:hidden;
6 display:flex;
7 flex-direction:column;
8 align-items:center;
9 justify-content:center;
10 padding:28px 24px;
11 background:linear-gradient(135deg,#ecfdf5 0%,#d1fae5 50%,#a7f3d0 100%);
12 border-radius:20px;
13 margin-bottom:22px;
14 box-shadow:0 4px 20px rgba(0,0,0,.08);
15 position:relative;
16 border:1px solid rgba(16,185,129,.12)
17}
关键点:
- 使用
height:160px+max-height:160px+overflow:hidden固定高度,避免内容撑开 ::before伪元素添加径向渐变光晕装饰- 图标添加
bounce动画,增加视觉活力
工具栏
工具栏采用 flex-wrap 布局,分组排列工具、颜色和画布选项:
1<div class="pixelart-toolbar">
2 <div class="pixelart-toolbar-group">
3 <span class="pixelart-toolbar-label">工具</span>
4 <button class="pixelart-tool-btn active" data-tool="pen">...</button>
5 <button class="pixelart-tool-btn" data-tool="eraser">...</button>
6 <button class="pixelart-tool-btn" data-tool="fill">...</button>
7 <button class="pixelart-tool-btn" data-tool="picker">...</button>
8 </div>
9 <div class="pixelart-toolbar-divider"></div>
10 <div class="pixelart-toolbar-group">
11 <span class="pixelart-toolbar-label">颜色</span>
12 <input type="color" id="pixelart-color" value="#1e293b">
13 <div class="pixelart-color-presets" id="pixelart-presets"></div>
14 </div>
15 <div class="pixelart-toolbar-divider"></div>
16 <div class="pixelart-toolbar-group">
17 <span class="pixelart-toolbar-label">画布</span>
18 <select id="pixelart-grid-size">
19 <option value="16">16 × 16</option>
20 <option value="32" selected>32 × 32</option>
21 <option value="64">64 × 64</option>
22 </select>
23 </div>
24</div>
工具按钮使用 data-tool 属性标识类型,点击时切换 active 类:
1.pixelart-tool-btn.active{
2 background:linear-gradient(135deg,#10b981,#059669);
3 color:#fff;
4 border-color:transparent;
5 box-shadow:0 4px 16px rgba(16,185,129,.35)
6}
Canvas 绘制核心
数据模型
使用二维数组存储像素颜色,null 表示透明:
1var gridSize = 32; // 画布尺寸(32×32)
2var pixelSize = 16; // 每个像素的渲染大小(px)
3var pixels = []; // 二维数组,pixels[y][x] = '#ef4444' | null
初始化时创建空画布:
1function resetPixels(){
2 pixels = [];
3 for(var y = 0; y < gridSize; y++){
4 pixels[y] = [];
5 for(var x = 0; x < gridSize; x++){
6 pixels[y][x] = null;
7 }
8 }
9 undoStack = [];
10 redoStack = [];
11}
自适应画布大小
根据容器宽度动态计算每个像素的渲染大小,确保画布不溢出:
1function resizeCanvas(){
2 var wrapper = canvas.parentElement;
3 var maxW = wrapper.clientWidth - 48;
4 pixelSize = Math.max(4, Math.floor(maxW / gridSize));
5 pixelSize = Math.min(pixelSize, 20); // 上限 20px
6 canvas.width = gridSize * pixelSize;
7 canvas.height = gridSize * pixelSize;
8}
渲染流程
每次操作后调用 render() 重绘整个画布:
1function render(){
2 ctx.clearRect(0, 0, canvas.width, canvas.height);
3
4 // 1. 棋盘格背景(表示透明区域)
5 for(var y = 0; y < gridSize; y++){
6 for(var x = 0; x < gridSize; x++){
7 if((x + y) % 2 === 0){
8 ctx.fillStyle = '#f8f9fa';
9 } else {
10 ctx.fillStyle = '#e9ecef';
11 }
12 ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
13
14 // 2. 像素颜色覆盖
15 if(pixels[y][x]){
16 ctx.fillStyle = pixels[y][x];
17 ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
18 }
19 }
20 }
21
22 // 3. 网格线(仅当像素≥6px时显示)
23 if(showGrid && pixelSize >= 6){
24 ctx.strokeStyle = 'rgba(0,0,0,0.08)';
25 ctx.lineWidth = 0.5;
26 for(var i = 0; i <= gridSize; i++){
27 ctx.beginPath();
28 ctx.moveTo(i * pixelSize, 0);
29 ctx.lineTo(i * pixelSize, gridSize * pixelSize);
30 ctx.stroke();
31 ctx.beginPath();
32 ctx.moveTo(0, i * pixelSize);
33 ctx.lineTo(gridSize * pixelSize, i * pixelSize);
34 ctx.stroke();
35 }
36 }
37}
棋盘格背景是像素画编辑器的标准设计,让用户直观区分透明区域和白色填充。
像素坐标转换
鼠标/触摸事件需要转换为画布上的像素坐标:
1function getPixelCoord(e){
2 var rect = canvas.getBoundingClientRect();
3 var scaleX = canvas.width / rect.width;
4 var scaleY = canvas.height / rect.height;
5 var x, y;
6 if(e.touches){
7 x = (e.touches[0].clientX - rect.left) * scaleX;
8 y = (e.touches[0].clientY - rect.top) * scaleY;
9 } else {
10 x = (e.clientX - rect.left) * scaleX;
11 y = (e.clientY - rect.top) * scaleY;
12 }
13 return {
14 x: Math.floor(x / pixelSize),
15 y: Math.floor(y / pixelSize)
16 };
17}
关键点:scaleX/scaleY 处理 CSS 缩放与 Canvas 实际尺寸不一致的情况。
四种绘制工具
画笔与橡皮擦
最基础的工具,画笔设置颜色,橡皮擦清除为 null:
1function drawPixel(x, y){
2 if(x < 0 || x >= gridSize || y < 0 || y >= gridSize) return;
3 if(currentTool === 'pen'){
4 pixels[y][x] = currentColor;
5 } else if(currentTool === 'eraser'){
6 pixels[y][x] = null;
7 }
8 render();
9}
拖拽绘制通过 mousedown → mousemove → mouseup 事件链实现,isDrawing 标志位控制:
1canvas.addEventListener('mousedown', function(e){
2 if(currentTool === 'fill' || currentTool === 'picker') { /* ... */ }
3 else {
4 saveState();
5 isDrawing = true;
6 drawPixel(coord.x, coord.y);
7 }
8});
9canvas.addEventListener('mousemove', function(e){
10 if(!isDrawing) return;
11 drawPixel(coord.x, coord.y);
12});
13document.addEventListener('mouseup', function(){ isDrawing = false; });
填充工具(Flood Fill)
使用栈式深度优先搜索实现泛洪填充算法:
1function floodFill(startX, startY, fillColor){
2 if(startX < 0 || startX >= gridSize || startY < 0 || startY >= gridSize) return;
3 var targetColor = pixels[startY][startX];
4 if(targetColor === fillColor) return; // 避免无限循环
5
6 var stack = [[startX, startY]];
7 var visited = {};
8
9 while(stack.length > 0){
10 var pos = stack.pop();
11 var px = pos[0], py = pos[1];
12 if(px < 0 || px >= gridSize || py < 0 || py >= gridSize) continue;
13 var key = px + ',' + py;
14 if(visited[key]) continue;
15 if(pixels[py][px] !== targetColor) continue;
16
17 visited[key] = true;
18 pixels[py][px] = fillColor;
19 stack.push([px+1,py],[px-1,py],[px,py+1],[px,py-1]);
20 }
21 render();
22}
关键点:
targetColor === fillColor判断避免同色填充导致的无限循环visited哈希表防止重复访问- 使用栈而非递归,避免大画布时栈溢出
取色器
点击画布上的像素获取其颜色,同步到颜色选择器和预设色板:
1function pickColor(x, y){
2 if(x < 0 || x >= gridSize || y < 0 || y >= gridSize) return;
3 var c = pixels[y][x];
4 if(c){
5 currentColor = c;
6 colorInput.value = c;
7 updateSwatchActive();
8 }
9}
撤销/重做系统
状态快照
每次操作前保存完整画布状态快照:
1var undoStack = [];
2var redoStack = [];
3var maxHistory = 50;
4
5function saveState(){
6 var state = pixels.map(function(row){ return row.slice(); });
7 undoStack.push(state);
8 if(undoStack.length > maxHistory) undoStack.shift(); // 限制历史长度
9 redoStack = []; // 新操作清空重做栈
10}
使用 Array.slice() 进行浅拷贝,因为每个像素是字符串(不可变),浅拷贝足够。
撤销与重做
1function undo(){
2 if(undoStack.length === 0) return;
3 var state = pixels.map(function(row){ return row.slice(); });
4 redoStack.push(state); // 当前状态压入重做栈
5 pixels = undoStack.pop(); // 恢复上一状态
6 render();
7}
8
9function redo(){
10 if(redoStack.length === 0) return;
11 var state = pixels.map(function(row){ return row.slice(); });
12 undoStack.push(state); // 当前状态压入撤销栈
13 pixels = redoStack.pop(); // 恢复下一状态
14 render();
15}
支持键盘快捷键 Ctrl+Z 撤销、Ctrl+Y 重做。
颜色系统
预设调色板
18 色预设覆盖常用颜色,通过 JS 动态生成色块:
1var PRESETS = [
2 '#1e293b','#ef4444','#f97316','#eab308','#22c55e','#10b981',
3 '#06b6d4','#3b82f6','#6366f1','#8b5cf6','#ec4899','#f43f5e',
4 '#ffffff','#94a3b8','#64748b','#334155','#000000','#78350f'
5];
6
7function buildPresets(){
8 presetsDiv.innerHTML = '';
9 PRESETS.forEach(function(c){
10 var el = document.createElement('div');
11 el.className = 'pixelart-color-swatch';
12 el.style.background = c;
13 if(c === '#ffffff') el.style.border = '2px solid #d1d5db'; // 白色加边框
14 el.dataset.color = c;
15 if(c === currentColor) el.classList.add('active');
16 presetsDiv.appendChild(el);
17 });
18}
自定义颜色
<input type="color"> 提供系统原生颜色选择器,与预设色板联动:
1colorInput.addEventListener('input', function(){
2 currentColor = this.value;
3 updateSwatchActive(); // 取消预设色板的 active 状态
4});
PNG 导出
导出时创建临时 Canvas,以 16 倍放大渲染,保持像素锐利边缘:
1document.getElementById('pixelart-export').addEventListener('click', function(){
2 var exportCanvas = document.createElement('canvas');
3 var scale = 16; // 每像素 16×16 px
4 exportCanvas.width = gridSize * scale;
5 exportCanvas.height = gridSize * scale;
6 var ectx = exportCanvas.getContext('2d');
7
8 // 透明背景,仅绘制有颜色的像素
9 for(var y = 0; y < gridSize; y++){
10 for(var x = 0; x < gridSize; x++){
11 if(pixels[y][x]){
12 ectx.fillStyle = pixels[y][x];
13 ectx.fillRect(x * scale, y * scale, scale, scale);
14 }
15 }
16 }
17
18 var link = document.createElement('a');
19 link.download = 'pixelart-' + gridSize + 'x' + gridSize + '-' + Date.now() + '.png';
20 link.href = exportCanvas.toDataURL('image/png');
21 link.click();
22});
关键点:
- 16 倍放大确保 32×32 画布导出 512×512 像素图片
- 透明背景(不绘制棋盘格),方便后续合成
- 文件名包含画布尺寸和时间戳
触摸屏适配
Canvas 默认触摸行为是滚动页面,需要阻止并转换为绘制事件:
1canvas.addEventListener('touchstart', function(e){
2 e.preventDefault(); // 阻止滚动
3 var coord = getPixelCoord(e);
4 // ... 与 mousedown 相同的逻辑
5}, {passive: false});
6
7canvas.addEventListener('touchmove', function(e){
8 e.preventDefault(); // 阻止滚动
9 if(!isDrawing) return;
10 var coord = getPixelCoord(e);
11 drawPixel(coord.x, coord.y);
12}, {passive: false});
13
14canvas.addEventListener('touchend', function(){ isDrawing = false; });
同时设置 touch-action: none CSS 属性,从根源禁止浏览器触摸手势:
1.pixelart-canvas{
2 touch-action: none;
3}
暗色模式
所有组件使用 CSS 变量 + [data-theme="dark"] 选择器适配暗色模式:
1[data-theme="dark"] .pixelart-page-header{
2 background:linear-gradient(135deg,#022c22 0%,#064e3b 50%,#022c22 100%);
3 border-color:rgba(16,185,129,.2)
4}
5[data-theme="dark"] .pixelart-canvas{
6 border-color:var(--border-color,#334155);
7 background:#1e293b
8}
9[data-theme="dark"] .pixelart-tool-btn{
10 background:rgba(30,41,59,.8);
11 color:#e2e8f0;
12 border-color:rgba(148,163,184,.15)
13}
暗色模式下棋盘格背景由 JS 检测主题动态调整,或使用 CSS 变量统一控制。
响应式设计
三个断点适配不同屏幕:
| 断点 | 容器 padding | Banner 高度 | 工具栏 |
|---|---|---|---|
| >900px | 40px | 160px | 完整展示 |
| 600-900px | 16px | 160px | 紧凑间距 |
| <600px | 12px | 140px | 最小间距 |
画布大小通过 resizeCanvas() 自动适配容器宽度,窗口 resize 时重新计算。
评论区集成
模板底部集成 Twikoo 评论系统,与主题其他页面风格统一:
1{{ if and site.Params.comments.enable (ne .Params.comments false) }}
2<div class="comments-section" style="margin-top:24px;padding:24px 28px;...">
3 <h3 class="comments-title">
4 <i class="fas fa-comment-dots" style="color:#10b981"></i> 留言评论
5 </h3>
6 {{ partial "comments.html" . }}
7</div>
8{{ end }}
评论图标颜色使用页面主题色 #10b981(绿色),与 Banner 渐变呼应。
菜单注册
在 hugo.toml 中注册菜单项,作为「探索」的子菜单:
1[[menu.main]]
2 identifier = "explore-pixelart"
3 name = "像素画"
4 url = "/pixelart/"
5 weight = 19
6 parent = "explore"
7 [menu.main.params]
8 icon = "fa-solid fa-th"
图标使用 fa-th(网格图标),直观表达像素画的特征。
技术要点总结
| 功能 | 实现方式 |
|---|---|
| 像素数据 | 二维数组 pixels[y][x],null 为透明 |
| 画布渲染 | Canvas 2D API,棋盘格 + 像素 + 网格线三层 |
| 画笔/橡皮擦 | mousedown+mousemove 事件链,isDrawing 标志位 |
| 填充 | 栈式 DFS 泛洪算法,visited 哈希防重 |
| 取色器 | 读取像素颜色,同步到 input[type=color] |
| 撤销/重做 | 双栈结构,最多 50 步历史 |
| 导出 PNG | 临时 Canvas 16× 放大渲染,透明背景 |
| 触摸屏 | preventDefault + touch-action:none |
| 暗色模式 | CSS 变量 + [data-theme=“dark”] 选择器 |
| 全宽布局 | :has(#pixelart-page) + $noSidebar 变量 |
留言评论
期待你的想法评论加载中