736 lines
41 KiB
JavaScript
736 lines
41 KiB
JavaScript
/* Original implementation, MIT licensed. No WebUI source is bundled. */
|
||
import { registerImageWheel } from "./wheel_gestures.js";
|
||
import { T } from "./i18n.js";
|
||
const el = (tag, cls, text) => {
|
||
const node = document.createElement(tag);
|
||
if (cls) node.className = cls;
|
||
if (text) node.textContent = text;
|
||
return node;
|
||
};
|
||
const makeCanvas = () => document.createElement("canvas");
|
||
const loadImage = (url) => new Promise((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => resolve(image);
|
||
image.onerror = () => reject(new Error(T("Cannot load image")));
|
||
image.src = url;
|
||
});
|
||
|
||
export class InlineMaskEditor {
|
||
constructor(options = {}) {
|
||
this.options = options;
|
||
this.root = el("div", "dd-inpaint");
|
||
this.root.tabIndex = 0;
|
||
this.image = null;
|
||
this.mask = makeCanvas();
|
||
this.baseMask = makeCanvas();
|
||
this.overlay = makeCanvas();
|
||
this.scene = makeCanvas();
|
||
this.maskRevision = 0;
|
||
this.tool = "brush";
|
||
this.view = "overlay";
|
||
this.brushSize = 64;
|
||
this.hardness = 100;
|
||
this.brushOpacity = 100;
|
||
this.overlayOpacity = 55;
|
||
this.zoom = 1;
|
||
this.pan = { x: 0, y: 0 };
|
||
this.history = [];
|
||
this.future = [];
|
||
this.dirty = false;
|
||
this.ready = false;
|
||
this.loadVersion = 0;
|
||
this.build();
|
||
this.observer = new ResizeObserver(() => this.schedule());
|
||
this.observer.observe(this.stage);
|
||
// Nodes 2.0 captures wheel events before widget handlers. Route only
|
||
// this canvas at window capture so image gestures stay inside it.
|
||
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
|
||
this.modifierDown = event => {
|
||
if (event.key !== "Alt" || !this.ready || !this.canvas.matches(":hover")) return;
|
||
event.preventDefault(); this.altErase = true; this.updateButtons(); this.schedule();
|
||
};
|
||
this.modifierUp = event => {
|
||
if (event.type !== "blur" && event.key !== "Alt") return;
|
||
this.altErase = false; this.updateButtons(); this.schedule();
|
||
};
|
||
window.addEventListener("keydown", this.modifierDown, true);
|
||
window.addEventListener("keyup", this.modifierUp, true);
|
||
window.addEventListener("blur", this.modifierUp);
|
||
this.root.addEventListener("keydown", (event) => this.keydown(event));
|
||
this.root.addEventListener("keyup", (event) => {
|
||
if (event.code === "Space") { this.space = false; this.stage.classList.remove("dd-pan"); }
|
||
if (event.key === "Alt") { this.altErase = false; this.updateButtons(); this.schedule(); }
|
||
});
|
||
this.root.addEventListener("blur", () => { this.space = false; this.altErase = false; this.updateButtons(); this.schedule(); }, true);
|
||
const paste = event => {
|
||
const file = [...(event.clipboardData?.files || [])].find(f => f.type.startsWith("image/"));
|
||
if (file) { event.preventDefault(); event.stopPropagation(); this.upload(file); }
|
||
};
|
||
this.root.addEventListener("paste", paste);
|
||
if (this.controls) {
|
||
this.controls.addEventListener("paste", paste);
|
||
this.controls.addEventListener("blur", () => { this.space = false; this.altErase = false; this.updateButtons(); this.schedule(); }, true);
|
||
this.controls.addEventListener("keydown", event => this.keydown(event));
|
||
this.controls.addEventListener("keyup", event => {
|
||
if (event.code === "Space") { this.space = false; this.stage.classList.remove("dd-pan"); }
|
||
if (event.key === "Alt") { this.altErase = false; this.updateButtons(); this.schedule(); }
|
||
});
|
||
}
|
||
}
|
||
|
||
button(parent, label, title, action, cls = "") {
|
||
const button = el("button", cls, label);
|
||
button.type = "button";
|
||
button.title = title;
|
||
button.setAttribute("aria-label", title);
|
||
button.onclick = (event) => { event.preventDefault(); action(event); };
|
||
parent.append(button);
|
||
return button;
|
||
}
|
||
|
||
build() {
|
||
const head = el("div", "dd-head");
|
||
this.sizeLabel = el("span", "dd-dimensions", T("INLINE MASK"));
|
||
head.append(this.sizeLabel);
|
||
this.root.append(head);
|
||
const files = el("div", "dd-row dd-files");
|
||
this.loadButton = this.button(files, "↑", T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
|
||
this.downloadButton = this.button(files, "↓", T("Download the source image"), () => this.downloadSource(), "dd-primary");
|
||
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
|
||
files.append(this.filename);
|
||
this.root.append(files);
|
||
this.imageInput = el("input");
|
||
this.imageInput.type = "file";
|
||
this.imageInput.accept = "image/*";
|
||
this.imageInput.hidden = true;
|
||
this.imageInput.onchange = () => { if (this.imageInput.files[0]) this.upload(this.imageInput.files[0]); this.imageInput.value = ""; };
|
||
this.maskInput = el("input");
|
||
this.maskInput.type = "file";
|
||
this.maskInput.accept = "image/*";
|
||
this.maskInput.hidden = true;
|
||
this.maskInput.onchange = async () => {
|
||
const file = this.maskInput.files[0];
|
||
if (file) {
|
||
const url = URL.createObjectURL(file);
|
||
try { this.action({ type: "import", image: await loadImage(url) }); }
|
||
catch (error) { this.status(error.message, true); }
|
||
finally { URL.revokeObjectURL(url); }
|
||
}
|
||
this.maskInput.value = "";
|
||
};
|
||
this.root.append(this.imageInput, this.maskInput);
|
||
const tools = el("div", "dd-row dd-tools");
|
||
this.toolButtons = {};
|
||
for (const [name, label, title] of [
|
||
["brush", "Brush", "Brush (B)"], ["erase", "Eraser", "Eraser (E); hold Alt for temporary erase"],
|
||
["rect", "Rectangle", "Rectangle (R)"], ["lasso", "Lasso", "Freehand lasso (L)"],
|
||
["pan", "Pan", "Pan (H), or hold Space"],
|
||
]) this.toolButtons[name] = this.button(tools, T(label), T(title), () => this.setTool(name));
|
||
this.root.append(tools);
|
||
const settings = el("div", "dd-settings");
|
||
this.addSlider(settings, T("Brush size"), "brushSize", 1, 512, "px");
|
||
this.addSlider(settings, T("Hardness"), "hardness", 0, 100, "%");
|
||
this.addSlider(settings, T("Brush opacity"), "brushOpacity", 1, 100, "%");
|
||
this.addSlider(settings, T("Overlay"), "overlayOpacity", 0, 100, "%");
|
||
this.stage = el("div", "dd-stage");
|
||
this.canvas = makeCanvas();
|
||
this.canvas.setAttribute("aria-label", T("Inline mask painting canvas; orange areas are repainted"));
|
||
this.stage.append(this.canvas);
|
||
this.empty = el("div", "dd-empty");
|
||
this.empty.append(el("span", "dd-empty-symbol", "+"), el("strong", "", T("Drop an image here")), el("span", "", T("Paint the areas to repaint directly in this node")));
|
||
this.stage.append(this.empty);
|
||
this.uploadIndicator = el("div", "dd-upload-progress");
|
||
this.uploadIndicator.hidden = true;
|
||
this.uploadIndicator.setAttribute("role", "status");
|
||
this.uploadIndicator.setAttribute("aria-live", "polite");
|
||
this.uploadLabel = el("span", "dd-upload-label");
|
||
this.uploadBar = el("progress", "dd-upload-bar");
|
||
this.uploadBar.max = 100;
|
||
this.uploadBar.setAttribute("aria-label", T("Image upload progress"));
|
||
this.uploadIndicator.append(this.uploadLabel, this.uploadBar);
|
||
this.root.append(this.stage, settings, this.uploadIndicator);
|
||
this.canvas.oncontextmenu = event => { event.preventDefault(); event.stopPropagation(); };
|
||
this.canvas.addEventListener("pointerdown", event => this.pointerDown(event));
|
||
this.canvas.addEventListener("pointermove", event => this.pointerMove(event));
|
||
this.canvas.addEventListener("pointerup", event => this.pointerUp(event));
|
||
this.canvas.addEventListener("pointercancel", event => this.pointerUp(event));
|
||
this.canvas.addEventListener("lostpointercapture", event => this.pointerUp(event));
|
||
this.canvas.addEventListener("pointerleave", () => { this.cursor = null; this.schedule(); });
|
||
this.stage.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); this.stage.classList.add("dd-drop"); });
|
||
this.stage.addEventListener("dragleave", () => this.stage.classList.remove("dd-drop"));
|
||
this.stage.addEventListener("drop", event => {
|
||
event.preventDefault(); event.stopPropagation(); this.stage.classList.remove("dd-drop");
|
||
const file = [...event.dataTransfer.files].find(file => file.type.startsWith("image/"));
|
||
if (file) this.upload(file);
|
||
});
|
||
const actions = el("div", "dd-row dd-actions");
|
||
this.actions = actions;
|
||
this.undoButton = this.button(actions, T("Undo"), T("Undo (Ctrl+Z)"), () => this.undo());
|
||
this.redoButton = this.button(actions, T("Redo"), T("Redo (Ctrl+Shift+Z)"), () => this.redo());
|
||
this.button(actions, T("Invert"), T("Invert the painted mask"), () => this.action({ type: "invert" }));
|
||
this.button(actions, T("Fill"), T("Fill the whole image with mask"), () => this.action({ type: "fill" }));
|
||
this.button(actions, T("Clear"), T("Clear the mask"), () => this.action({ type: "clear" }));
|
||
this.button(actions, T("Import mask"), T("White repaints, black preserves"), () => { if (this.ready) this.maskInput.click(); });
|
||
this.button(actions, T("Export"), T("Export the mask PNG at full resolution"), () => this.download());
|
||
this.root.append(actions);
|
||
const footer = el("div", "dd-row dd-viewbar");
|
||
this.viewButtons = {};
|
||
for (const [name, label] of [["overlay", "Overlay"], ["image", "Image"], ["mask", "Mask"]]) {
|
||
const text = T(label);
|
||
this.viewButtons[name] = this.button(footer, text, `${text} / ${name}`, () => { this.view = name; this.updateButtons(); this.schedule(); });
|
||
}
|
||
const spacer = el("span", "dd-spacer");
|
||
footer.append(spacer);
|
||
this.zoomLabel = el("span", "dd-zoom", "100%");
|
||
footer.append(this.zoomLabel);
|
||
this.fitButton = this.button(footer, "⛶", T("Fit the image to the canvas"), () => this.fit());
|
||
this.keepButton = this.button(footer, "", T("Pin the current view"), event => { event.stopPropagation(); this.setKeepFit(!this.keepFit); });
|
||
this.keepButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-4 1-4 4 1 4-3 3-3-6-6-3 3-3 4 1 4-4zM8 16l-6 6"/></svg>';
|
||
// The inline button must not select/bring forward the ComfyUI node:
|
||
// newer frontends can remeasure its DOM widget on that bubbling click.
|
||
const fitAction = this.fitButton.onclick;
|
||
this.fitButton.onclick = event => { event.stopPropagation(); fitAction(event); };
|
||
this.root.append(footer);
|
||
const embedRow = el("label", "dd-embed");
|
||
this.embed = el("input");
|
||
this.embed.type = "checkbox";
|
||
this.embed.onchange = () => this.options.onEmbed?.(this.embed.checked);
|
||
embedRow.append(this.embed, document.createTextNode(T("Embed the source image in the workflow; no separate file needed when sharing")));
|
||
this.root.append(embedRow);
|
||
this.message = el("div", "dd-status", T("Alt = temporary erase · Ctrl+wheel = brush size · wheel = zoom · Space = pan"));
|
||
this.message.hidden = true;
|
||
this.message.setAttribute("role", "alert");
|
||
this.root.append(this.message);
|
||
if (this.options.compact) {
|
||
this.root.classList.add("dd-inline-minimal");
|
||
this.empty.hidden = true;
|
||
this.controls = el("div", "dd-inpaint dd-editor-controls");
|
||
const info = el("div", "dd-row dd-source-info"); info.append(this.filename, this.sizeLabel);
|
||
head.remove();
|
||
this.controls.append(info, tools, settings, actions, footer, embedRow);
|
||
actions.addEventListener("keydown", event => this.keydown(event));
|
||
this.loadButton.after(this.fitButton);
|
||
this.fitButton.after(this.keepButton);
|
||
this.loadButton.after(this.downloadButton);
|
||
// These are the original live controls; the sampler mounts this
|
||
// same DOM tree so brush state and callbacks are preserved.
|
||
}
|
||
this.setTool("brush");
|
||
this.updateButtons();
|
||
}
|
||
|
||
addSlider(parent, title, key, min, max, unit) {
|
||
const label = el("label", "dd-slider");
|
||
const row = el("span", "dd-slider-label");
|
||
const value = el("output", "", `${this[key]}${unit}`);
|
||
const input = el("input");
|
||
input.type = "range"; input.min = min; input.max = max; input.value = this[key];
|
||
input.setAttribute("aria-label", title);
|
||
input.oninput = () => { this[key] = Number(input.value); value.textContent = `${this[key]}${unit}`; this.schedule(); };
|
||
row.append(document.createTextNode(title), value);
|
||
label.append(row, input);
|
||
parent.append(label);
|
||
this[`${key}Input`] = input;
|
||
}
|
||
|
||
status(message, error = false) {
|
||
this.message.textContent = message;
|
||
this.message.classList.toggle("dd-error", error);
|
||
// Only errors stay visible; transient hints and progress lines hide.
|
||
this.message.hidden = !error;
|
||
}
|
||
|
||
notice(message) {
|
||
// An explicit non-error notice that should remain visible.
|
||
this.message.textContent = message;
|
||
this.message.classList.remove("dd-error");
|
||
this.message.hidden = false;
|
||
}
|
||
|
||
async upload(file) {
|
||
if (file.size > 64 * 1024 * 1024) { this.status(T("File is too large (max 64 MB)"), true); return; }
|
||
this.uploadController?.abort();
|
||
const controller=this.uploadController=new AbortController();
|
||
const version = this.uploadUiVersion = (this.uploadUiVersion || 0) + 1;
|
||
const progress = ({phase, loaded = 0, total = 0}) => {
|
||
if (version !== this.uploadUiVersion || this.destroyed) return;
|
||
this.uploadIndicator.hidden = false;
|
||
if (phase === "upload" && total > 0) {
|
||
const percent = Math.max(0, Math.min(100, Math.round(100 * loaded / total)));
|
||
this.uploadLabel.textContent = `${T("Uploading image")} ${percent}%`;
|
||
this.uploadBar.value = percent;
|
||
} else {
|
||
this.uploadLabel.textContent = T(phase === "processing" ? "Upload complete, loading image…" : phase === "authenticated" ? "Uploading with authentication…" : "Uploading image…");
|
||
this.uploadBar.removeAttribute("value");
|
||
}
|
||
};
|
||
progress({phase: "upload"});
|
||
try {
|
||
this.status(T("Loading image…"));
|
||
// Keep the painted mask when a new source replaces the current one,
|
||
// the same way adopting a result does. A first upload starts empty.
|
||
await this.options.onUpload?.(file, { preserveMask: !!this.image, onProgress: progress, signal:controller.signal });
|
||
} catch (error) {
|
||
if (version === this.uploadUiVersion && !this.destroyed) this.status(error.message, true);
|
||
} finally {
|
||
if(this.uploadController===controller)this.uploadController=null;
|
||
if (version === this.uploadUiVersion && !this.destroyed) this.uploadIndicator.hidden = true;
|
||
}
|
||
}
|
||
|
||
async setImage(url, maskData = "", name = "") {
|
||
this.stroke = null; this.pointerId = null;
|
||
const version = ++this.loadVersion;
|
||
const wasReady=this.ready;
|
||
this.ready = false;
|
||
try {
|
||
const [image, loadedMask] = await Promise.all([loadImage(url), maskData ? loadImage(maskData) : Promise.resolve(null)]);
|
||
if (version !== this.loadVersion || this.destroyed) return;
|
||
if (image.width * image.height > 32_000_000 || Math.max(image.width, image.height) > 16384) {
|
||
throw new Error(T("Image is too large; keep it under 32 MP and 16384 px per side"));
|
||
}
|
||
this.image = image;
|
||
for (const canvas of [this.mask, this.baseMask]) { canvas.width = image.width; canvas.height = image.height; }
|
||
this.history = []; this.future = [];
|
||
const ctx = this.mask.getContext("2d", { willReadFrequently: true });
|
||
if (loadedMask) {
|
||
this.importMask(ctx, loadedMask);
|
||
} else {
|
||
// Match Comfy's LoadImage convention: transparent source pixels are masked.
|
||
ctx.drawImage(image, 0, 0);
|
||
const pixels = ctx.getImageData(0, 0, image.width, image.height);
|
||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||
const alpha = 255 - pixels.data[i + 3];
|
||
pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = 255;
|
||
pixels.data[i + 3] = alpha;
|
||
}
|
||
ctx.putImageData(pixels, 0, 0);
|
||
}
|
||
if (version !== this.loadVersion || this.destroyed) return;
|
||
this.baseMask.getContext("2d").drawImage(this.mask, 0, 0);
|
||
this.sizeLabel.textContent = `${image.width} × ${image.height}`;
|
||
this.filename.textContent = name || T("Embedded image");
|
||
this.filename.title = this.filename.textContent;
|
||
this.empty.hidden = true;
|
||
this.ready = true;
|
||
this.dirty = false;
|
||
this.lastMask = maskData;
|
||
this.fit();
|
||
this.updateButtons();
|
||
this.options.onImage?.(image);
|
||
this.status(T("Orange is the painted mask · Alt = temporary erase · Ctrl+wheel = brush size"));
|
||
} catch (error) {
|
||
if (version === this.loadVersion && !this.destroyed) {this.ready=!!this.image && wasReady;this.updateButtons();this.schedule();this.status(error.message, true);}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async replaceSource(url, name = "") {
|
||
const version = ++this.loadVersion;
|
||
const image = await loadImage(url);
|
||
if (version !== this.loadVersion || this.destroyed) return false;
|
||
// A different size cannot keep the painted mask; report it so the caller
|
||
// can fall back to a fresh load instead of failing the upload.
|
||
if (image.width !== this.mask.width || image.height !== this.mask.height) return false;
|
||
// Replacing only the source preserves mask pixels, the base snapshot,
|
||
// command history, redo history, active strokes and the current view.
|
||
this.flush();
|
||
this.image = image;
|
||
this.filename.textContent = name || T("Embedded image");
|
||
this.filename.title = this.filename.textContent;
|
||
this.updateButtons(); this.schedule();
|
||
return true;
|
||
}
|
||
|
||
importMask(ctx, image) {
|
||
const scratch = makeCanvas();
|
||
scratch.width = this.mask.width; scratch.height = this.mask.height;
|
||
const s = scratch.getContext("2d", { willReadFrequently: true });
|
||
s.drawImage(image, 0, 0, scratch.width, scratch.height);
|
||
const pixels = s.getImageData(0, 0, scratch.width, scratch.height);
|
||
let hasAlpha = false;
|
||
for (let i = 3; i < pixels.data.length; i += 4) if (pixels.data[i] !== 255) { hasAlpha = true; break; }
|
||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||
const a = hasAlpha ? pixels.data[i + 3] : Math.round(pixels.data[i] * .299 + pixels.data[i + 1] * .587 + pixels.data[i + 2] * .114);
|
||
pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = 255;
|
||
pixels.data[i + 3] = a;
|
||
}
|
||
ctx.clearRect(0, 0, this.mask.width, this.mask.height);
|
||
ctx.putImageData(pixels, 0, 0);
|
||
scratch.width = scratch.height = 0;
|
||
}
|
||
|
||
setTool(tool) {
|
||
this.tool = tool;
|
||
if (this.stage) this.stage.classList.toggle("dd-pan", tool === "pan");
|
||
this.updateButtons();
|
||
}
|
||
|
||
updateButtons() {
|
||
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
|
||
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
|
||
if (this.downloadButton) this.downloadButton.disabled = !this.image;
|
||
this.stage?.classList.toggle("dd-erasing", !!(this.altErase || this.tool === "erase" || this.stroke?.type === "erase"));
|
||
for (const [name, button] of Object.entries(this.toolButtons || {})) {
|
||
const active = name === (this.altErase ? "erase" : this.tool);
|
||
button.classList.toggle("dd-active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
}
|
||
for (const [name, button] of Object.entries(this.viewButtons || {})) {
|
||
button.classList.toggle("dd-active", name === this.view);
|
||
button.setAttribute("aria-pressed", String(name === this.view));
|
||
}
|
||
if (this.undoButton) this.undoButton.disabled = !this.history.length;
|
||
if (this.redoButton) this.redoButton.disabled = !this.future.length;
|
||
}
|
||
|
||
dimensions() {
|
||
const r = this.canvas.getBoundingClientRect();
|
||
if (this.viewport?.width && this.viewport?.height && r.width && r.height) {
|
||
this.pan.x *= r.width / this.viewport.width;
|
||
this.pan.y *= r.height / this.viewport.height;
|
||
}
|
||
this.viewport = { width: r.width, height: r.height };
|
||
const scale = this.image ? Math.min(r.width / this.image.width, r.height / this.image.height) * this.zoom : 1;
|
||
return { width: r.width, height: r.height, scale, x: (r.width - (this.image?.width || 0) * scale) / 2 + this.pan.x, y: (r.height - (this.image?.height || 0) * scale) / 2 + this.pan.y };
|
||
}
|
||
|
||
point(event) {
|
||
const r = this.canvas.getBoundingClientRect();
|
||
const d = this.dimensions();
|
||
return { x: (event.clientX - r.left - d.x) / d.scale, y: (event.clientY - r.top - d.y) / d.scale, p: event.pointerType === "pen" ? Math.max(.05, event.pressure) : 1 };
|
||
}
|
||
|
||
pointerDown(event) {
|
||
if (event.button === 2) { event.preventDefault(); event.stopPropagation(); return; }
|
||
if (!this.ready || this.pointerId != null) return;
|
||
event.preventDefault(); event.stopPropagation(); this.root.focus({ preventScroll: true });
|
||
this.altErase = !!event.altKey; this.updateButtons();
|
||
this.pointerId = event.pointerId;
|
||
this.canvas.setPointerCapture(event.pointerId);
|
||
this.cursor = this.point(event);
|
||
if (this.space || (!event.altKey && this.tool === "pan") || event.button === 1) {
|
||
this.drag = { x: event.clientX, y: event.clientY, pan: { ...this.pan } };
|
||
return;
|
||
}
|
||
if (this.view === "image") { this.view = "overlay"; this.updateButtons(); }
|
||
this.stroke = { type: event.altKey ? "erase" : this.tool, size: this.brushSize, hardness: this.hardness, opacity: this.brushOpacity, points: [this.cursor] };
|
||
this.updateButtons();
|
||
if (["brush", "erase"].includes(this.stroke.type)) this.paintSegment(this.mask.getContext("2d"), this.stroke, this.cursor, this.cursor);
|
||
this.schedule();
|
||
}
|
||
|
||
pointerMove(event) {
|
||
if (!this.ready) return;
|
||
if (this.altErase !== !!event.altKey) { this.altErase = !!event.altKey; this.updateButtons(); }
|
||
this.cursor = this.point(event);
|
||
if (event.pointerId === this.pointerId) {
|
||
event.preventDefault(); event.stopPropagation();
|
||
if (this.drag) {
|
||
this.pan.x = this.drag.pan.x + event.clientX - this.drag.x;
|
||
this.pan.y = this.drag.pan.y + event.clientY - this.drag.y;
|
||
} else if (this.stroke) {
|
||
const events = event.getCoalescedEvents?.() || [event];
|
||
if (["brush", "erase"].includes(this.stroke.type)) {
|
||
const type = event.altKey ? "erase" : this.tool;
|
||
if (["brush", "erase"].includes(type) && type !== this.stroke.type) {
|
||
// Keep separate commands when Alt is pressed/released mid-stroke.
|
||
const pivot = this.stroke.points.at(-1);
|
||
this.remember(this.stroke);
|
||
this.stroke = { ...this.stroke, type, points: [pivot] };
|
||
this.paintSegment(this.mask.getContext("2d"), this.stroke, pivot, pivot);
|
||
}
|
||
}
|
||
for (const e of events.length ? events : [event]) {
|
||
const point = this.point(e);
|
||
const previous = this.stroke.points.at(-1);
|
||
if (Math.hypot(point.x - previous.x, point.y - previous.y) < .2) continue;
|
||
if (["brush", "erase"].includes(this.stroke.type)) this.paintSegment(this.mask.getContext("2d"), this.stroke, previous, point);
|
||
this.stroke.points.push(point);
|
||
}
|
||
}
|
||
}
|
||
this.schedule();
|
||
}
|
||
|
||
pointerUp(event) {
|
||
if (event.pointerId !== this.pointerId) return;
|
||
event.preventDefault(); event.stopPropagation();
|
||
if (this.stroke) {
|
||
const command = this.stroke;
|
||
this.stroke = null;
|
||
if (["rect", "lasso"].includes(command.type)) this.drawCommand(this.mask.getContext("2d"), command);
|
||
this.remember(command);
|
||
this.changed();
|
||
}
|
||
this.drag = null;
|
||
const id = this.pointerId; this.pointerId = null;
|
||
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
|
||
this.updateButtons();
|
||
this.schedule();
|
||
}
|
||
|
||
paintSegment(ctx, command, a, b) {
|
||
if (ctx.canvas === this.mask) this.maskRevision++;
|
||
ctx.save();
|
||
const bounds = this.options.paintBounds?.();
|
||
if (bounds) { ctx.beginPath(); ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.clip(); }
|
||
ctx.globalCompositeOperation = command.type === "erase" ? "destination-out" : "source-over";
|
||
ctx.globalAlpha = command.opacity / 100;
|
||
if (command.hardness >= 99 && a.p === 1 && b.p === 1) {
|
||
ctx.strokeStyle = "white"; ctx.fillStyle = "white"; ctx.lineWidth = command.size;
|
||
ctx.lineCap = "round"; ctx.lineJoin = "round";
|
||
if (a.x === b.x && a.y === b.y) { ctx.beginPath(); ctx.arc(a.x, a.y, command.size / 2, 0, Math.PI * 2); ctx.fill(); }
|
||
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }
|
||
} else {
|
||
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||
const count = Math.max(1, Math.ceil(length / Math.max(1, command.size * .15)));
|
||
for (let i = 0; i <= count; i++) {
|
||
const t = i / count, x = a.x + (b.x - a.x) * t, y = a.y + (b.y - a.y) * t;
|
||
const radius = Math.max(.5, command.size * (a.p + (b.p - a.p) * t) / 2);
|
||
if (command.hardness >= 99) {
|
||
ctx.fillStyle = "white"; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill();
|
||
} else {
|
||
const gradient = ctx.createRadialGradient(x, y, radius * command.hardness / 100, x, y, radius);
|
||
gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||
ctx.fillStyle = gradient; ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
||
}
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
drawCommand(ctx, command) {
|
||
ctx.save();
|
||
const bounds = this.options.paintBounds?.();
|
||
if (bounds) { ctx.beginPath(); ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.clip(); }
|
||
const w = this.mask.width, h = this.mask.height;
|
||
if (["brush", "erase"].includes(command.type)) {
|
||
this.paintSegment(ctx, command, command.points[0], command.points[0]);
|
||
for (let i = 1; i < command.points.length; i++) this.paintSegment(ctx, command, command.points[i - 1], command.points[i]);
|
||
} else if (command.type === "clear") ctx.clearRect(0, 0, w, h);
|
||
else if (command.type === "fill") { ctx.save(); ctx.fillStyle = "white"; ctx.fillRect(0, 0, w, h); ctx.restore(); }
|
||
else if (command.type === "invert") {
|
||
ctx.save(); ctx.globalCompositeOperation = "xor"; ctx.fillStyle = "white"; ctx.fillRect(0, 0, w, h); ctx.restore();
|
||
} else if (command.type === "import") {
|
||
if (!bounds) this.importMask(ctx, command.image);
|
||
else {
|
||
const imported = makeCanvas(); imported.width = w; imported.height = h;
|
||
this.importMask(imported.getContext("2d"), command.image);
|
||
ctx.clearRect(0, 0, w, h); ctx.drawImage(imported, 0, 0); imported.width = imported.height = 0;
|
||
}
|
||
}
|
||
else if (["rect", "lasso"].includes(command.type)) {
|
||
const a = command.points[0], b = command.points.at(-1);
|
||
ctx.save(); ctx.fillStyle = "white"; ctx.globalAlpha = command.opacity / 100;
|
||
if (command.type === "rect") ctx.fillRect(a.x, a.y, b.x - a.x, b.y - a.y);
|
||
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); for (const p of command.points) ctx.lineTo(p.x, p.y); ctx.closePath(); ctx.fill(); }
|
||
ctx.restore();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
remember(command) {
|
||
this.history.push(command); this.future = [];
|
||
// Keep commands rather than full-resolution pixel snapshots; memory stays small.
|
||
let bitmapBytes = this.history.reduce((total, item) => total + (item.image ? item.image.width * item.image.height * 4 : 0), 0);
|
||
while (this.history.length > 500 || (this.history.length > 1 && bitmapBytes > 64 * 1024 * 1024)) {
|
||
const first = this.history.shift();
|
||
this.drawCommand(this.baseMask.getContext("2d"), first);
|
||
bitmapBytes -= first.image ? first.image.width * first.image.height * 4 : 0;
|
||
}
|
||
this.updateButtons();
|
||
}
|
||
|
||
action(command) {
|
||
if (!this.ready) return;
|
||
this.drawCommand(this.mask.getContext("2d"), command); this.remember(command); this.changed();
|
||
}
|
||
|
||
replay() {
|
||
const ctx = this.mask.getContext("2d");
|
||
ctx.clearRect(0, 0, this.mask.width, this.mask.height); ctx.drawImage(this.baseMask, 0, 0);
|
||
for (const command of this.history) this.drawCommand(ctx, command);
|
||
this.updateButtons(); this.changed();
|
||
}
|
||
|
||
undo() { if (this.history.length && !this.stroke) { this.future.push(this.history.pop()); this.replay(); } }
|
||
redo() { if (this.future.length && !this.stroke) { this.history.push(this.future.pop()); this.replay(); } }
|
||
|
||
changed() {
|
||
this.maskRevision++;
|
||
this.dirty = true;
|
||
this.flush();
|
||
this.schedule();
|
||
this.options.onDirty?.();
|
||
}
|
||
|
||
flush() {
|
||
if (!this.ready || (!this.dirty && !this.stroke)) return this.lastMask || "";
|
||
const output = makeCanvas();
|
||
output.width = this.mask.width; output.height = this.mask.height;
|
||
const ctx = output.getContext("2d");
|
||
if (this.stroke && ["rect", "lasso"].includes(this.stroke.type)) {
|
||
// Shape strokes are previews until pointer-up; include them in Save/Run.
|
||
ctx.drawImage(this.mask, 0, 0);
|
||
this.drawCommand(ctx, this.stroke);
|
||
ctx.globalCompositeOperation = "destination-over";
|
||
ctx.fillStyle = "black"; ctx.fillRect(0, 0, output.width, output.height);
|
||
} else {
|
||
ctx.fillStyle = "black"; ctx.fillRect(0, 0, output.width, output.height); ctx.drawImage(this.mask, 0, 0);
|
||
}
|
||
this.lastMask = output.toDataURL("image/png");
|
||
output.width = output.height = 0;
|
||
this.dirty = false;
|
||
this.options.onChange?.(this.lastMask);
|
||
return this.lastMask;
|
||
}
|
||
|
||
sourceData() {
|
||
if (!this.ready) return "";
|
||
const canvas = makeCanvas(); canvas.width = this.image.width; canvas.height = this.image.height;
|
||
canvas.getContext("2d").drawImage(this.image, 0, 0);
|
||
const data = canvas.toDataURL("image/png"); canvas.width = canvas.height = 0; return data;
|
||
}
|
||
|
||
download() {
|
||
if (!this.ready) return;
|
||
this.dirty = true;
|
||
const link = el("a"); link.download = "adachi-mask.png"; link.href = this.flush(); link.click();
|
||
}
|
||
|
||
downloadSource() {
|
||
if (!this.image) return;
|
||
const out = makeCanvas();
|
||
out.width = this.image.naturalWidth || this.image.width;
|
||
out.height = this.image.naturalHeight || this.image.height;
|
||
out.getContext("2d").drawImage(this.image, 0, 0);
|
||
const label = String(this.filename.textContent || "").split(/[\\/]/).pop().replace(/\.[^.]+$/, "");
|
||
const link = el("a");
|
||
link.download = `${label || "adachi-source"}.png`;
|
||
link.href = out.toDataURL("image/png");
|
||
link.click();
|
||
out.width = out.height = 0;
|
||
}
|
||
|
||
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
|
||
|
||
setKeepFit(enabled, notify = true) {
|
||
// Pin freezes the view exactly as drawn; it must not re-fit the image.
|
||
this.keepFit = !!enabled;
|
||
this.updateButtons(); this.schedule();
|
||
if (notify) this.options.onKeepFit?.(this.keepFit);
|
||
}
|
||
|
||
setBrushSize(value) {
|
||
this.brushSizeInput.value = Math.max(1, Math.min(512, Math.round(value)));
|
||
this.brushSizeInput.dispatchEvent(new Event("input"));
|
||
}
|
||
|
||
wheel(event) {
|
||
if (!this.ready) return;
|
||
event.preventDefault(); event.stopPropagation();
|
||
if (event.shiftKey) {
|
||
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
|
||
this.hardnessInput.value = Math.max(0, Math.min(100, this.hardness + (delta < 0 ? 5 : delta > 0 ? -5 : 0)));
|
||
this.hardnessInput.dispatchEvent(new Event("input"));
|
||
this.cursor = this.point(event); this.schedule(); return;
|
||
}
|
||
if (this.keepFit && !event.ctrlKey) { this.options.onGraphWheel?.(event); return; }
|
||
if (event.ctrlKey) {
|
||
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
|
||
if (delta) {
|
||
const scaled = this.brushSize * Math.exp(Math.max(-4, Math.min(4, -delta * .002)));
|
||
// Small wheel deltas must still move by at least one pixel.
|
||
this.setBrushSize(delta < 0 ? Math.max(this.brushSize + 1, scaled) : Math.min(this.brushSize - 1, scaled));
|
||
}
|
||
this.cursor = this.point(event);
|
||
this.schedule();
|
||
return;
|
||
}
|
||
const before = this.point(event);
|
||
this.zoom = Math.max(.1, Math.min(32, this.zoom * Math.exp(-event.deltaY * .0015)));
|
||
const r = this.canvas.getBoundingClientRect(), d = this.dimensions();
|
||
this.pan.x += event.clientX - r.left - (before.x * d.scale + d.x);
|
||
this.pan.y += event.clientY - r.top - (before.y * d.scale + d.y);
|
||
this.schedule();
|
||
}
|
||
|
||
keydown(event) {
|
||
if (["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) return;
|
||
if (event.key === "Alt") { event.preventDefault(); event.stopPropagation(); this.altErase = true; this.updateButtons(); this.schedule(); return; }
|
||
const key = event.key.toLowerCase();
|
||
if ((event.ctrlKey || event.metaKey) && key === "z") { event.preventDefault(); event.stopPropagation(); event.shiftKey ? this.redo() : this.undo(); }
|
||
else if ((event.ctrlKey || event.metaKey) && key === "y") { event.preventDefault(); event.stopPropagation(); this.redo(); }
|
||
else if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||
else if (event.code === "Space") { event.preventDefault(); event.stopPropagation(); this.space = true; this.stage.classList.add("dd-pan"); }
|
||
else if ({ b: "brush", e: "erase", r: "rect", l: "lasso", h: "pan" }[key]) { event.preventDefault(); event.stopPropagation(); this.setTool({ b: "brush", e: "erase", r: "rect", l: "lasso", h: "pan" }[key]); }
|
||
else if (["[", "]"].includes(key)) { event.preventDefault(); event.stopPropagation(); this.setBrushSize(this.brushSize + (key === "]" ? 8 : -8)); }
|
||
}
|
||
|
||
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
|
||
|
||
render() {
|
||
const view = this.options.displayView?.() || this.view;
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return;
|
||
const d = this.dimensions();
|
||
const ratio = Math.min(devicePixelRatio || 1, 2, this.ready ? 1 / d.scale : 2,
|
||
8192 / rect.width, 8192 / rect.height, Math.sqrt(8 * 1024 * 1024 / (rect.width * rect.height)));
|
||
const w = Math.max(1, Math.round(rect.width * ratio)), h = Math.max(1, Math.round(rect.height * ratio));
|
||
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
|
||
const display = this.canvas.getContext("2d");
|
||
if (!this.ready) { display.clearRect(0, 0, w, h); this.sceneKey = null; return; }
|
||
const key = [this.loadVersion, this.maskRevision, w, h, d.scale * ratio, d.x * ratio, d.y * ratio, view, this.overlayOpacity, this.options.sceneRevision?.() || 0].join(":");
|
||
if (key !== this.sceneKey) {
|
||
if (this.scene.width !== w || this.scene.height !== h) { this.scene.width = w; this.scene.height = h; }
|
||
const ctx = this.scene.getContext("2d");
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
|
||
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||
if (view !== "mask") { if (!this.options.drawSource?.(ctx)) ctx.drawImage(this.image, 0, 0); }
|
||
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
|
||
if (view !== "image" && (view === "mask" || this.options.showMask?.() !== false)) {
|
||
const overlay = this.overlay.getContext("2d");
|
||
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
|
||
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||
overlay.drawImage(this.mask, 0, 0);
|
||
if (view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
|
||
}
|
||
this.sceneKey = key;
|
||
}
|
||
const ctx = display;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.drawImage(this.scene, 0, 0);
|
||
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||
this.options.drawOverlay?.(ctx, d);
|
||
if (this.stroke && ["rect", "lasso"].includes(this.stroke.type)) {
|
||
ctx.save(); ctx.strokeStyle = "#ffcf85"; ctx.lineWidth = 1.5 / d.scale; ctx.setLineDash([5 / d.scale, 4 / d.scale]);
|
||
const a = this.stroke.points[0], b = this.stroke.points.at(-1);
|
||
if (this.stroke.type === "rect") ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
|
||
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); for (const p of this.stroke.points) ctx.lineTo(p.x, p.y); ctx.stroke(); }
|
||
ctx.restore();
|
||
}
|
||
if (this.cursor && (this.altErase || ["brush", "erase"].includes(this.tool)) && !this.drag && this.options.showCursor?.() !== false) {
|
||
const erasing = this.altErase || this.tool === "erase" || this.stroke?.type === "erase";
|
||
ctx.save();
|
||
if (erasing) ctx.setLineDash([4 / d.scale, 3 / d.scale]);
|
||
ctx.beginPath(); ctx.arc(this.cursor.x, this.cursor.y, this.brushSize * this.cursor.p / 2, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "#15191f"; ctx.lineWidth = 3 / d.scale; ctx.stroke(); ctx.strokeStyle = erasing ? "#ffd0d7" : "white"; ctx.lineWidth = 1 / d.scale; ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||
}
|
||
|
||
destroy() {
|
||
this.uploadController?.abort();++this.uploadUiVersion;
|
||
this.destroyed = true; ++this.loadVersion;
|
||
cancelAnimationFrame(this.frame); this.observer.disconnect();
|
||
this.stopWheelCapture();
|
||
window.removeEventListener("keydown", this.modifierDown, true);
|
||
window.removeEventListener("keyup", this.modifierUp, true);
|
||
window.removeEventListener("blur", this.modifierUp);
|
||
for (const canvas of [this.mask, this.baseMask, this.overlay, this.scene, this.canvas]) canvas.width = canvas.height = 0;
|
||
this.history = []; this.future = []; this.image = null; this.controls?.remove(); this.root.remove();
|
||
}
|
||
}
|