mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
5.1 KiB
JavaScript
142 lines
5.1 KiB
JavaScript
/**
|
|
* Paint Bucket (flood fill) tool.
|
|
*
|
|
* `floodFillImageData` and `hexToRgb` are pure helpers (exported for tests);
|
|
* `createBucketTool` fills the active layer's contiguous matching pixels —
|
|
* confined to the active selection mask when there is one — like Photoshop's
|
|
* contiguous Paint Bucket.
|
|
*/
|
|
import { state } from '../state.js';
|
|
import { canvasCoords } from '../canvas-coords.js';
|
|
|
|
export function hexToRgb(color) {
|
|
const hex = String(color || '').replace('#', '');
|
|
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
|
|
if (full.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(full)) return [0, 0, 0];
|
|
const n = parseInt(full, 16);
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
|
|
function matches(data, i, target, tolerance) {
|
|
return Math.abs(data[i] - target[0]) <= tolerance
|
|
&& Math.abs(data[i + 1] - target[1]) <= tolerance
|
|
&& Math.abs(data[i + 2] - target[2]) <= tolerance
|
|
&& Math.abs(data[i + 3] - target[3]) <= tolerance;
|
|
}
|
|
|
|
/**
|
|
* Scanline flood fill over an RGBA buffer, in place.
|
|
*
|
|
* @param {Uint8ClampedArray} data RGBA pixels (width*height*4).
|
|
* @param {number} width
|
|
* @param {number} height
|
|
* @param {number} startX
|
|
* @param {number} startY
|
|
* @param {[number,number,number]} rgb fill colour (alpha forced opaque).
|
|
* @param {{tolerance?: number, mask?: Uint8ClampedArray|null}} options
|
|
* `mask` is an optional RGBA alpha buffer; only pixels whose mask alpha
|
|
* is >= 128 are eligible (confines the fill to a selection).
|
|
* @returns {number} number of pixels filled.
|
|
*/
|
|
export function floodFillImageData(data, width, height, startX, startY, rgb, options = {}) {
|
|
const tolerance = Math.max(0, Math.min(255, options.tolerance == null ? 32 : options.tolerance));
|
|
const mask = options.mask || null;
|
|
const x0 = Math.floor(startX);
|
|
const y0 = Math.floor(startY);
|
|
if (x0 < 0 || y0 < 0 || x0 >= width || y0 >= height) return 0;
|
|
const startIdx = (y0 * width + x0) * 4;
|
|
if (mask && mask[startIdx + 3] < 128) return 0;
|
|
const target = [data[startIdx], data[startIdx + 1], data[startIdx + 2], data[startIdx + 3]];
|
|
const fill = [rgb[0], rgb[1], rgb[2], 255];
|
|
if (target[3] === 255
|
|
&& Math.abs(target[0] - fill[0]) <= tolerance
|
|
&& Math.abs(target[1] - fill[1]) <= tolerance
|
|
&& Math.abs(target[2] - fill[2]) <= tolerance) return 0;
|
|
|
|
const visited = new Uint8Array(width * height);
|
|
const eligible = (px, py) => {
|
|
const vi = py * width + px;
|
|
if (visited[vi]) return false;
|
|
const i = vi * 4;
|
|
if (mask && mask[i + 3] < 128) return false;
|
|
return matches(data, i, target, tolerance);
|
|
};
|
|
|
|
const stack = [[x0, y0]];
|
|
let filled = 0;
|
|
while (stack.length) {
|
|
const [sx, sy] = stack.pop();
|
|
let x = sx;
|
|
while (x >= 0 && eligible(x, sy)) x--;
|
|
x++;
|
|
let spanUp = false;
|
|
let spanDown = false;
|
|
while (x < width && eligible(x, sy)) {
|
|
visited[sy * width + x] = 1;
|
|
const i = (sy * width + x) * 4;
|
|
data[i] = fill[0];
|
|
data[i + 1] = fill[1];
|
|
data[i + 2] = fill[2];
|
|
data[i + 3] = 255;
|
|
filled++;
|
|
if (sy > 0) {
|
|
const up = eligible(x, sy - 1);
|
|
if (up && !spanUp) { stack.push([x, sy - 1]); spanUp = true; }
|
|
else if (!up) spanUp = false;
|
|
}
|
|
if (sy < height - 1) {
|
|
const down = eligible(x, sy + 1);
|
|
if (down && !spanDown) { stack.push([x, sy + 1]); spanDown = true; }
|
|
else if (!down) spanDown = false;
|
|
}
|
|
x++;
|
|
}
|
|
}
|
|
return filled;
|
|
}
|
|
|
|
export function createBucketTool({ activeLayer, saveState, composite, renderLayerPanel, invalidateLayerCaches, showToast }) {
|
|
function maskFor(layer) {
|
|
if (state.wandMask
|
|
&& state.wandMask.width === layer.canvas.width
|
|
&& state.wandMask.height === layer.canvas.height) {
|
|
return state.wandMask.getContext('2d')
|
|
.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Flood-fill `layer` at layer-local pixel (x, y). Returns pixels filled.
|
|
function fillAtPoint(layer, x, y, { color, tolerance } = {}) {
|
|
if (!layer?.canvas || !layer.ctx) return 0;
|
|
const lx = Math.floor(x);
|
|
const ly = Math.floor(y);
|
|
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return 0;
|
|
const tol = Math.max(0, Math.min(255, tolerance == null ? (Number(state.bucketTolerance) || 0) : Number(tolerance)));
|
|
const img = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height);
|
|
const count = floodFillImageData(
|
|
img.data, layer.canvas.width, layer.canvas.height, lx, ly,
|
|
hexToRgb(color || state.color), { tolerance: tol, mask: maskFor(layer) },
|
|
);
|
|
if (!count) return 0;
|
|
saveState?.();
|
|
layer.ctx.putImageData(img, 0, 0);
|
|
invalidateLayerCaches?.(layer);
|
|
composite?.();
|
|
renderLayerPanel?.();
|
|
return count;
|
|
}
|
|
|
|
function fill(e) {
|
|
const layer = activeLayer();
|
|
if (!layer?.canvas || !layer.ctx) return 0;
|
|
const point = canvasCoords(e, state.mainCanvas);
|
|
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
|
const count = fillAtPoint(layer, point.x - off.x, point.y - off.y);
|
|
if (!count) showToast?.('Nothing to fill there');
|
|
return count;
|
|
}
|
|
|
|
return { fill, fillAtPoint };
|
|
}
|