mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 15:22:24 +01:00
feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user