type RGBA = [number, number, number, number]; function colorDistance(a: RGBA, b: RGBA): number { // Weight alpha at 25% so transparent regions fill correctly 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 ); } 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 { 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; const target: RGBA = [ pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3], ]; 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; const current: RGBA = [ pixels[ci], pixels[ci + 1], pixels[ci + 2], pixels[ci + 3], ]; 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; }); }