Search code examples
javascriptcanvasfabricjs

Flood fill compatibility using Fabric JS


I have initialized a fabric.js canvas and I tried to implement a flood fill algorithm. I have a problem rendering the image Data back to the fabric canvas and the only way I found is creating a temporary canvas, pass the image data on this canvas and adding it back to the initial fabric canvas as an image. However I can't use the rest of the Fabric js tools like selection because after I use the flood fill algorithm it considers the drawn rectangles as an image and not as individual objects.

Here is my code:

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, //this is set to true whenever I select the respective tool
    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);
        }
    }
});
//------------------------ Helper function to add rectangles ------------------------//
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);
}
//---------------------------------  Flood Fill -------------------------------------//
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("Bad Hex");
}

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 };

    // Check if base color and new color are the same
    if (colorMatch(baseColor, newColor)) {
        return;
    }

    // Add the clicked location to stack
    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;

        // Move to top most contiguousDown pixel
        while (contiguousUp && operator.y >= 0) {
            operator.y--;
            contiguousUp = colorMatch(
                getColorAtPixel(imageData, operator.x, operator.y),
                baseColor
            );
        }

        // Move downward
        while (contiguousDown && operator.y < height) {
            setColorAtPixel(imageData, newColor, operator.x, operator.y);

            // Check left
            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;
            }

            // Check right
            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
            );
        }
    }

    // Create a new canvas element and draw the modified imageData onto it
    let tempCanvas = document.createElement("canvas");
    tempCanvas.width = width;
    tempCanvas.height = height;
    let tempCtx = tempCanvas.getContext("2d", { willReadFrequently: true });
    tempCtx.putImageData(imageData, 0, 0);

    // Create a new fabric.Image object with the new canvas as the source
    let newImage = new fabric.Image(tempCanvas, {
        left: 0,
        top: 0,
        selectable: true,
        evented: false,
    });

    // Remove the existing fabric object from the canvas
    canvas.remove(canvas.item(0));
    
    // Add the new fabric.Image object to the canvas
    canvas.add(newImage);
    
    // Render the canvas to reflect the changes
    canvas.renderAll();
}   

Solution

  • I see the issue. You can avoid using the HTML canvas altogether and work with the fabricjs canvas alone. Since you have a grid size of 20, my guess is the given code performs too many iterations. Let's simplify that a little.

    First of all, you can avoid checking for neighboring pixels using flags, as they unnecessarily complicate the code. You can try adding new sets of coordinates for every iteration and check to see if the enclosed color to be painted matches the color as the x and y approach the borders of the area, or if this set of coordinates has already been visited to skip the current iteration. If this condition is not met, you can then use fabricjs' built-in Rect function to paint each pixel. Since your grid size is 20px, you are trying to paint blocks of pixels as one object of a total of 20*20 pixels.

    Finally, you need to push to your stack with a step of 20, equal to the grid size. This should limit the number of iterations to the number of blocks painted with fill.

    function floodFill

    function floodFill(newColor, x, y) {
        const baseColor = getColorAtPxl(x, y);
        if (colorMatch(baseColor, newColor)) {
          return;
        }
    
        const stack = [];
        const processed = new Set();
        stack.push({ x, y });
    
        while (stack.length) {
          const operator = stack.pop();
          const pxlColor = getColorAtPxl(operator.x, operator.y);
          if (
            !colorMatch(pxlColor, baseColor) ||
            processed.has(`${operator.x}-${operator.y}`)
          ) {
            continue;
          }
    
          processed.add(`${operator.x}-${operator.y}`);
    
          const rect = new fabric.Rect({
            left: Math.floor(operator.x / gridSize) * gridSize,
            top: Math.floor(operator.y / gridSize) * gridSize,
            width: gridSize,
            height: gridSize,
            fill: newColor,
            selectable: true,
            evented: false
          });
    
          canvas.add(rect);
    
          stack.push({ x: operator.x + gridSize, y: operator.y });
          stack.push({ x: operator.x - gridSize, y: operator.y });
          stack.push({ x: operator.x, y: operator.y + gridSize });
          stack.push({ x: operator.x, y: operator.y - gridSize });
        }
    
        canvas.renderAll();
    }
    

    function getColorAtPixel

    function getColorAtPixel(x, y) {
        const ctx = canvas.getContext("2d");
        const pixel = ctx.getImageData(x, y, 1, 1).data;
    
        return {
          r: pixel[0],
          g: pixel[1],
          b: pixel[2],
          a: pixel[3]
        };
    }
    

    I hope this helps simplify and solve the problem. Let me know.