Files
T
adachi 3664cbf1d8 Follow the ComfyUI language for the plugin's own canvas and panel labels
ComfyUI's locales/ feature only covers node definitions, so the custom painting
controls and parameter panel are translated here instead, by reading Comfy.Locale.

English stays the built-in default, which keeps the Manager listing usable for
the general audience. When the ComfyUI language is Chinese the canvas buttons and
the parameter panel labels switch to Chinese as well; any key without a
translation falls back to English.
2026-10-15 19:33:10 +08:00

204 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Original hover-comparison UI, MIT licensed. */
import { registerImageWheel } from "./wheel_gestures.js";
import { T } from "./i18n.js";
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
const imageFrom = url => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(T("Preview unavailable; run the workflow again")));
image.src = url;
});
export class ImageCompareView {
constructor(options) {
this.options = options;
this.root = element("div", "dd-inpaint dd-comparer");
this.originals = []; this.results = []; this.index = 0;
this.zoom = 1; this.pan = { x: 0, y: 0 }; this.split = .5;
this.version = 0; this.ready = false;
this.dimensionsLabel = element("span", "dd-dimensions");
const toolbar = element("div", "dd-row dd-compare-toolbar");
toolbar.append(element("span", "dd-compare-label", T("Result")));
this.resultSelect = element("select"); this.resultSelect.setAttribute("aria-label", T("Choose result"));
this.resultSelect.onchange = () => { this.index = Number(this.resultSelect.value); this.options.onSelect?.(this.index); this.loadSelected(); };
toolbar.append(this.resultSelect, this.dimensionsLabel);
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
const fit = element("button", "", T("Fit")); fit.type = "button";
fit.onclick = () => { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }; toolbar.append(fit);
this.stage = element("div", "dd-stage dd-compare-stage");
this.canvas = document.createElement("canvas"); this.canvas.tabIndex = 0;
this.canvas.setAttribute("aria-label", T("Result left, original right; hover to compare"));
this.canvas.oncontextmenu = event => event.preventDefault();
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
this.stage.append(this.canvas);
this.empty = element("div", "dd-empty", T("Connect an original image and a result, then run the workflow")); this.stage.append(this.empty);
const adoption = element("div", "dd-row dd-adoption");
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
this.targetSelect = element("select"); this.targetSelect.setAttribute("aria-label", T("Target inline editor"));
this.targetSelect.onfocus = () => this.refreshTargets();
this.targetSelect.onchange = () => { this.manualTarget = true; this.updateButton(); };
adoption.append(this.targetSelect);
this.adoptButton = element("button", "dd-primary", T("Adopt this result")); this.adoptButton.type = "button";
this.adoptButton.onclick = () => this.adopt(); adoption.append(this.adoptButton);
this.message = element("div", "dd-status", T("Move to compare · wheel to zoom · middle-drag to pan"));
this.message.hidden = true; this.message.setAttribute("role", "alert");
this.root.append(this.stage, toolbar, adoption, this.message);
this.canvas.addEventListener("pointermove", event => this.move(event));
this.canvas.addEventListener("pointerdown", event => {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation(); this.canvas.focus({ preventScroll: true });
if (event.button === 1 || this.space) {
this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, pan: { ...this.pan } };
this.canvas.setPointerCapture(event.pointerId);
} else this.move(event);
});
const finish = event => {
if (!this.drag || this.drag.id !== event.pointerId) return;
const id = this.drag.id; this.drag = null;
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
};
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) this.canvas.addEventListener(name, finish);
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
this.canvas.addEventListener("keydown", event => {
if (event.code === "Space") { event.preventDefault(); event.stopPropagation(); this.space = true; }
else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault(); event.stopPropagation();
this.split = Math.max(0, Math.min(1, this.split + (event.key === "ArrowRight" ? .02 : -.02))); this.schedule();
}
});
this.canvas.addEventListener("keyup", event => { if (event.code === "Space") this.space = false; });
this.canvas.addEventListener("blur", () => { this.space = false; });
this.observer = new ResizeObserver(() => this.schedule()); this.observer.observe(this.stage);
this.refreshTargets(); this.updateButton();
}
status(text, error = false) { this.message.textContent = text; this.message.classList.toggle("dd-error", error); this.message.hidden = !error; }
refreshTargets() {
const selected = this.targetSelect.value;
const { editors = [], preferred = "" } = this.options.getTargets?.() || {};
this.targetSelect.replaceChildren();
const placeholder = element("option", "", T("Select an inline mask")); placeholder.value = ""; this.targetSelect.append(placeholder);
for (const editor of editors) {
const option = element("option", "", editor.label); option.value = String(editor.id); this.targetSelect.append(option);
}
const valid = id => editors.some(editor => String(editor.id) === String(id));
this.targetSelect.value = this.manualTarget && valid(selected) ? selected : valid(preferred) ? String(preferred) : editors.length === 1 ? String(editors[0].id) : "";
this.updateButton();
}
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; this.adoptButton.textContent = this.busy ? T("Adopting…") : T("Adopt this result"); }
async setData(data, index = this.index) {
this.originals = Array.isArray(data?.originals) ? data.originals : [];
this.results = Array.isArray(data?.results) ? data.results : [];
this.index = Math.max(0, Math.min(this.results.length - 1, Number(index) || 0));
this.resultSelect.replaceChildren();
this.results.forEach((_, i) => {
const option = element("option", "", `${i + 1} / ${this.results.length}`); option.value = String(i); this.resultSelect.append(option);
});
this.resultSelect.value = String(this.index);
this.resultSelect.disabled = this.results.length < 2;
this.refreshTargets();
await this.loadSelected();
}
async loadSelected() {
const version = ++this.version;
this.ready = false; this.updateButton();
if (!this.originals.length || !this.results.length) return;
const repetitions = this.results.length / this.originals.length;
const original = this.originals[Math.min(this.originals.length - 1, Math.floor(this.index / repetitions))];
try {
const images = await Promise.all([imageFrom(this.options.resolveUrl(original)), imageFrom(this.options.resolveUrl(this.results[this.index]))]);
if (version !== this.version || this.destroyed) return;
this.before = images[0]; this.after = images[1];
this.ready = true; this.empty.hidden = true;
this.dimensionsLabel.textContent = `${this.before.width} × ${this.before.height}`;
this.status("");
this.updateButton(); this.schedule();
} catch (error) { if (version === this.version && !this.destroyed) this.status(error.message, true); }
}
async adopt() {
this.refreshTargets();
if (!this.ready || !this.targetSelect.value || this.busy) return;
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
try {
await this.options.onAdopt(target, result);
if (!this.destroyed) this.status(T("Adopted: back in the inline mask, keep painting to inpaint again"));
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally { this.busy = false; if (!this.destroyed) this.updateButton(); }
}
transform() {
const rect = this.canvas.getBoundingClientRect();
if (this.viewport?.width && this.viewport?.height && rect.width && rect.height) {
this.pan.x *= rect.width / this.viewport.width;
this.pan.y *= rect.height / this.viewport.height;
}
this.viewport = { width: rect.width, height: rect.height };
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) * this.zoom : 1;
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2 + this.pan.x, y: (rect.height - (this.before?.height || 0) * scale) / 2 + this.pan.y };
}
position(event) { const rect = this.canvas.getBoundingClientRect(), t = this.transform(); return { x: (event.clientX - rect.left - t.x) / t.scale, y: (event.clientY - rect.top - t.y) / t.scale }; }
wheel(event) {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation();
const before = this.position(event), rect = this.canvas.getBoundingClientRect();
this.zoom = Math.max(.1, Math.min(32, this.zoom * Math.exp(-event.deltaY * .0015)));
const t = this.transform();
this.pan.x += event.clientX - rect.left - (t.x + before.x * t.scale);
this.pan.y += event.clientY - rect.top - (t.y + before.y * t.scale);
this.schedule();
}
move(event) {
if (!this.ready) return;
this.hovered = true;
if (this.drag) {
event.preventDefault(); event.stopPropagation();
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 this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.schedule();
}
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
render() {
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
if (!t.width || !t.height) return;
const width = Math.round(t.width * ratio), height = Math.round(t.height * ratio);
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height);
if (!this.ready) return;
ctx.setTransform(t.scale * ratio, 0, 0, t.scale * ratio, t.x * ratio, t.y * ratio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
if (!this.hovered) return;
const split = this.split * this.before.width;
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
ctx.drawImage(this.before, 0, 0); ctx.restore();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const lineX = Math.round((t.x + split * t.scale) * ratio) + .5;
ctx.beginPath(); ctx.moveTo(lineX, t.y * ratio); ctx.lineTo(lineX, (t.y + this.before.height * t.scale) * ratio);
ctx.strokeStyle = "#ffffffb3"; ctx.lineWidth = 1; ctx.stroke();
}
destroy() {
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
this.stopWheelCapture();
this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
}
}