2026-05-21 00:02:12 +07:00
|
|
|
type RGBA = [number, number, number, number];
|
|
|
|
|
|
|
|
|
|
function colorDistance(a: RGBA, b: RGBA): number {
|
|
|
|
|
// Weight alpha at 25% so transparent regions fill correctly
|
2026-05-31 03:03:49 +07:00
|
|
|
return (
|
|
|
|
|
Math.abs(a[0] - b[0]) +
|
|
|
|
|
Math.abs(a[1] - b[1]) +
|
|
|
|
|
Math.abs(a[2] - b[2]) +
|
|
|
|
|
Math.abs(a[3] - b[3]) * 0.25
|
|
|
|
|
);
|
2026-05-21 00:02:12 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean {
|
|
|
|
|
// Fully transparent pixels all match each other regardless of RGB
|
|
|
|
|
if (target[3] === 0) return pixel[3] <= tolerance;
|
|
|
|
|
return colorDistance(pixel, target) <= tolerance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hexToRgba(hex: string): RGBA {
|
|
|
|
|
const clean = hex.replace("#", "");
|
|
|
|
|
const r = parseInt(clean.slice(0, 2), 16);
|
|
|
|
|
const g = parseInt(clean.slice(2, 4), 16);
|
|
|
|
|
const b = parseInt(clean.slice(4, 6), 16);
|
|
|
|
|
const a = clean.length === 8 ? parseInt(clean.slice(6, 8), 16) : 255;
|
|
|
|
|
return [r, g, b, a];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function floodFillDataUrl(
|
|
|
|
|
sourceUri: string,
|
|
|
|
|
px: number,
|
|
|
|
|
py: number,
|
|
|
|
|
fillColor: string,
|
|
|
|
|
tolerance: number = 32,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const image = new Image();
|
|
|
|
|
image.crossOrigin = "anonymous";
|
|
|
|
|
image.onload = () => {
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = image.naturalWidth;
|
|
|
|
|
canvas.height = image.naturalHeight;
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
reject(new Error("Cannot get canvas context"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.drawImage(image, 0, 0);
|
|
|
|
|
const { width, height } = canvas;
|
|
|
|
|
const data = ctx.getImageData(0, 0, width, height);
|
|
|
|
|
const pixels = data.data;
|
|
|
|
|
|
|
|
|
|
const x = Math.round(px);
|
|
|
|
|
const y = Math.round(py);
|
|
|
|
|
if (x < 0 || x >= width || y < 0 || y >= height) {
|
|
|
|
|
resolve(sourceUri);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const idx = (y * width + x) * 4;
|
2026-05-31 03:03:49 +07:00
|
|
|
const target: RGBA = [
|
|
|
|
|
pixels[idx],
|
|
|
|
|
pixels[idx + 1],
|
|
|
|
|
pixels[idx + 2],
|
|
|
|
|
pixels[idx + 3],
|
|
|
|
|
];
|
2026-05-21 00:02:12 +07:00
|
|
|
const fill = hexToRgba(fillColor);
|
|
|
|
|
|
|
|
|
|
if (matchesTarget(target, fill, 0)) {
|
|
|
|
|
resolve(sourceUri);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visited = new Uint8Array(width * height);
|
|
|
|
|
const stack: number[] = [x + y * width];
|
|
|
|
|
|
|
|
|
|
while (stack.length > 0) {
|
|
|
|
|
const pos = stack.pop()!;
|
|
|
|
|
if (visited[pos]) continue;
|
|
|
|
|
visited[pos] = 1;
|
|
|
|
|
|
|
|
|
|
const cx = pos % width;
|
|
|
|
|
const cy = Math.floor(pos / width);
|
|
|
|
|
const ci = pos * 4;
|
2026-05-31 03:03:49 +07:00
|
|
|
const current: RGBA = [
|
|
|
|
|
pixels[ci],
|
|
|
|
|
pixels[ci + 1],
|
|
|
|
|
pixels[ci + 2],
|
|
|
|
|
pixels[ci + 3],
|
|
|
|
|
];
|
2026-05-21 00:02:12 +07:00
|
|
|
|
|
|
|
|
if (!matchesTarget(current, target, tolerance)) continue;
|
|
|
|
|
|
|
|
|
|
pixels[ci] = fill[0];
|
|
|
|
|
pixels[ci + 1] = fill[1];
|
|
|
|
|
pixels[ci + 2] = fill[2];
|
|
|
|
|
pixels[ci + 3] = fill[3];
|
|
|
|
|
|
|
|
|
|
if (cx > 0) stack.push(pos - 1);
|
|
|
|
|
if (cx < width - 1) stack.push(pos + 1);
|
|
|
|
|
if (cy > 0) stack.push(pos - width);
|
|
|
|
|
if (cy < height - 1) stack.push(pos + width);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.putImageData(data, 0, 0);
|
|
|
|
|
resolve(canvas.toDataURL("image/png"));
|
|
|
|
|
};
|
|
|
|
|
image.onerror = reject;
|
|
|
|
|
image.src = sourceUri;
|
|
|
|
|
});
|
|
|
|
|
}
|