基于Fabric JS实现Flood Fill后无法保留对象独立可编辑性问题
问题描述
已初始化Fabric.js画布并实现Flood Fill(填充)算法,但将处理后的ImageData渲染回画布时,只能通过创建临时画布、绘制ImageData后以Fabric.Image形式替换原画布内容。此操作会将原本独立的矩形对象合并为一张图片,导致无法使用Fabric JS的选择等工具操作单个对象。
现有代码
画布初始化
let col = { r: 0, g: 0, b: 0, a: 0xff }; let gridSize = 20; const canvas = new fabric.Canvas("canvas", { fireRightClick: true, stopContextMenu: true, selection: false, // 切换到对应工具时会设为true skipTargetFind: false, preserveObjectStacking: true, backgroundColor: "#ffffff", });
鼠标事件处理
canvas.on("mouse:down", function (event) { let pointer = canvas.getPointer(event.e); let gridX = Math.floor(pointer.x / gridSize) * gridSize; let gridY = Math.floor(pointer.y / gridSize) * gridSize; let x = Math.round(pointer.x); let y = Math.round(pointer.y); if (event.e.button === 0) { if (buttonStates.pencil) { addRectangle(gridX, gridY, canvas.freeDrawingBrush.color); } else if (buttonStates.fill) { hexToRgbA(); floodFill(col, x, y); } } });
添加矩形辅助函数
//------------------------ 辅助函数:添加矩形 ------------------------// function addRectangle(left, top, fill, width = gridSize, height = gridSize) { let rect = new fabric.Rect({ left: left, top: top, width: width, height: height, fill: fill, evented: false, }); canvas.add(rect); }
原填充算法实现
//--------------------------------- 填充算法实现 -------------------------------------// function hexToRgbA() { let hex = colorInput.value; let c; if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { c = hex.substring(1).split(""); if (c.length == 3) { c = [c[0], c[0], c[1], c[1], c[2], c[2]]; } c = "0x" + c.join(""); const r = (c >> 16) & 255; const g = (c >> 8) & 255; const b = c & 255; col.r = r; col.g = g; col.b = b; return "rgba(" + r + "," + g + "," + b + ",1)"; } throw new Error("无效的十六进制颜色值"); } function getColorAtPixel(imageData, x, y) { const { width, data } = imageData; return { a: data[4 * (width * y + x) + 0], r: data[4 * (width * y + x) + 1], g: data[4 * (width * y + x) + 2], b: data[4 * (width * y + x) + 3], }; } function setColorAtPixel(imageData, color, x, y) { const { width, data } = imageData; data[4 * (width * y + x) + 0] = color.r & 0xff; data[4 * (width * y + x) + 1] = color.g & 0xff; data[4 * (width * y + x) + 2] = color.b & 0xff; data[4 * (width * y + x) + 3] = color.a & 0xff; } function colorMatch(a, b) { return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; } function floodFill(newColor, x, y) { let htmlCanvas = canvas.toCanvasElement(); let ctx = htmlCanvas.getContext("2d", { willReadFrequently: true }); let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { width, height, data } = imageData; const stack = []; const baseColor = getColorAtPixel(imageData, x, y); let operator = { x, y }; // 如果目标颜色和当前颜色一致,直接返回 if (colorMatch(baseColor, newColor)) { return; } // 将点击位置加入栈 stack.push({ x: operator.x, y: operator.y }); while (stack.length) { operator = stack.pop(); let contiguousDown = true; let contiguousUp = true; let contiguousLeft = false; let contiguousRight = false; // 移动到最上方的连续同色像素 while (contiguousUp && operator.y >= 0) { operator.y--; contiguousUp = colorMatch( getColorAtPixel(imageData, operator.x, operator.y), baseColor ); } // 向下遍历 while (contiguousDown && operator.y < height) { setColorAtPixel(imageData, newColor, operator.x, operator.y); // 检查左侧 if ( operator.x - 1 >= 0 && colorMatch( getColorAtPixel(imageData, operator.x - 1, operator.y), baseColor ) ) { if (!contiguousLeft) { contiguousLeft = true; stack.push({ x: operator.x - 1, y: operator.y }); } } else { contiguousLeft = false; } // 检查右侧 if ( operator.x + 1 < width && colorMatch( getColorAtPixel(imageData, operator.x + 1, operator.y), baseColor ) ) { if (!contiguousRight) { stack.push({ x: operator.x + 1, y: operator.y }); contiguousRight = true; } } else { contiguousRight = false; } operator.y++; contiguousDown = colorMatch( getColorAtPixel(imageData, operator.x, operator.y), baseColor ); } } // 创建临时画布并绘制修改后的ImageData let tempCanvas = document.createElement("canvas"); tempCanvas.width = width; tempCanvas.height = height; let tempCtx = tempCanvas.getContext("2d", { willReadFrequently: true }); tempCtx.putImageData(imageData, 0, 0); // 创建新的Fabric.Image对象 let newImage = new fabric.Image(tempCanvas, { left: 0, top: 0, selectable: true, evented: false, }); // 移除原有对象并添加新图片 canvas.remove(canvas.item(0)); canvas.add(newImage); canvas.renderAll(); }
解决方案
核心问题是直接操作画布像素数据并替换为单张图片,破坏了Fabric的对象模型。要保留单个矩形的可操作性,需基于Fabric的fabric.Rect对象实现填充逻辑,而非像素级操作。
修改思路
- 放弃像素级ImageData操作,直接操作画布上的独立矩形对象
- 利用网格布局特性,快速定位点击的矩形,通过广度优先搜索找到所有连通的同色矩形
- 修改这些矩形的
fill属性,保留对象独立性
修改后的填充实现
// 转换颜色格式:将Fabric颜色字符串转成RGB对象,用于匹配 function colorStrToRgb(colorStr) { let result; if (colorStr.startsWith('#')) { const hex = colorStr.slice(1); const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); result = { r, g, b }; } else if (colorStr.startsWith('rgba')) { const parts = colorStr.match(/\d+/g); result = { r: parseInt(parts[0]), g: parseInt(parts[1]), b: parseInt(parts[2]) }; } else { // 处理颜色名称(如red) const tempCanvas = document.createElement('canvas'); const tempCtx = tempCanvas.getContext('2d'); tempCtx.fillStyle = colorStr; tempCtx.fillRect(0,0,1,1); const pixel = tempCtx.getImageData(0,0,1,1).data; result = { r: pixel[0], g: pixel[1], b: pixel[2] }; } return result; } // 判断两个颜色对象是否匹配 function colorObjMatch(a, b) { return a.r === b.r && a.g === b.g && a.b === b.b; } // 修改后的填充函数,基于Fabric对象操作 function floodFill(newColorHex, x, y) { // 1. 找到点击位置对应的矩形 const targetRect = canvas.getObjects().find(rect => { return rect instanceof fabric.Rect && rect.containsPoint({ x, y }); }); if (!targetRect) return; // 2. 获取目标颜色和新颜色的RGB对象 const targetColor = colorStrToRgb(targetRect.fill); const newColorRgb = colorStrToRgb(newColorHex); // 如果颜色相同,直接返回 if (colorObjMatch(targetColor, newColorRgb)) return; // 3. 广度优先搜索找连通的同色矩形 const visited = new Set(); const queue = [targetRect]; visited.add(targetRect); while (queue.length > 0) { const currentRect = queue.shift(); // 修改当前矩形颜色 currentRect.set('fill', newColorHex); // 获取当前矩形的网格坐标 const currentGridX = currentRect.left / gridSize; const currentGridY = currentRect.top / gridSize; // 检查上下左右四个方向的邻居矩形 const directions = [ { dx: -1, dy: 0 }, // 左 { dx: 1, dy: 0 }, // 右 { dx: 0, dy: -1 }, // 上 { dx: 0, dy: 1 } // 下 ]; directions.forEach(dir => { const targetGridX = currentGridX + dir.dx; const targetGridY = currentGridY + dir.dy; // 找到对应网格位置的矩形 const neighborRect = canvas.getObjects().find(rect => { return rect instanceof fabric.Rect && rect.left === targetGridX * gridSize && rect.top === targetGridY * gridSize; }); // 如果邻居存在、未被访问、颜色匹配,加入队列 if (neighborRect && !visited.has(neighborRect)) { const neighborColor = colorStrToRgb(neighborRect.fill); if (colorObjMatch(neighborColor, targetColor)) { visited.add(neighborRect); queue.push(neighborRect); } } }); } // 重新渲染画布 canvas.renderAll(); }
配套修改鼠标事件
调整填充函数的调用参数,直接传入十六进制颜色值:
canvas.on("mouse:down", function (event) { let pointer = canvas.getPointer(event.e); let gridX = Math.floor(pointer.x / gridSize) * gridSize; let gridY = Math.floor(pointer.y / gridSize) * gridSize; let x = Math.round(pointer.x); let y = Math.round(pointer.y); if (event.e.button === 0) { if (buttonStates.pencil) { addRectangle(gridX, gridY, canvas.freeDrawingBrush.color); } else if (buttonStates.fill) { // 直接传入颜色输入框的十六进制值 floodFill(colorInput.value, x, y); } } });
方案优势
- 完全基于Fabric对象模型,所有矩形保持独立实例,保留选择、编辑等原生功能
- 利用网格布局特性,搜索逻辑比像素级填充更高效简洁
- 无需创建临时画布或替换整个画布内容,直接修改对象属性即可完成填充
内容的提问来源于stack exchange,提问作者DImitris K
相关产品推荐
相关产品推荐

