You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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对象实现填充逻辑,而非像素级操作。

修改思路

  1. 放弃像素级ImageData操作,直接操作画布上的独立矩形对象
  2. 利用网格布局特性,快速定位点击的矩形,通过广度优先搜索找到所有连通的同色矩形
  3. 修改这些矩形的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.14 10:25:55