Files
2026-09-07 20:26:18 +03:00

493 lines
15 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
app.registerExtension({
name: "iTools.compareNode",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "iToolsCompareImage") {
nodeType.prototype.onExecuted = function (message) {
delete this.imgs;
delete this.images;
if (this._itoolsOnExecuted) {
this._itoolsOnExecuted(message);
}
};
}
},
async nodeCreated(node) {
if (node.comfyClass !== "iToolsCompareImage") {
return;
}
delete node.imgs;
delete node.images;
const MIN_WIDTH = 360;
const MIN_HEIGHT = 360;
// Prevent default Litegraph preview drawing
node.onDrawBackground = function (ctx) {
// Intentionally empty: all rendering handled by custom DOM widget
};
node.onExecuted = function (message) {
delete node.imgs;
delete node.images;
if (node._itoolsOnExecuted) {
node._itoolsOnExecuted(message);
}
};
// State
let imgA = null;
let imgB = null;
let compareMode = "|"; // "A", "B", "|", "O"
let mousePos = { x: 0, y: 0 };
let isHovering = false;
let useCompareStroke = false;
// ── DOM Construction ───────────────────────────────────────────────────
const container = document.createElement("div");
container.className = "itools-compare-widget";
container.style.cssText = `
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
background: #181818;
border-radius: 8px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
user-select: none;
`;
container.addEventListener(
"wheel",
(e) => {
if (app.canvas?.processMouseWheel) {
app.canvas.processMouseWheel(e);
} else {
const targetCanvas =
app.canvas?.canvas ||
document.querySelector("canvas.graph-canvas") ||
document.querySelector(".litegraph canvas");
if (targetCanvas) {
targetCanvas.dispatchEvent(new WheelEvent("wheel", e));
}
}
},
{ passive: false },
);
// Toolbar
const toolbar = document.createElement("div");
toolbar.className = "itools-compare-toolbar";
toolbar.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: #222222;
border-bottom: 1px solid #333333;
flex-shrink: 0;
box-sizing: border-box;
`;
function createToolbarButton(label, tooltip, mode) {
const btn = document.createElement("button");
btn.textContent = label;
btn.title = tooltip;
btn.style.cssText = `
background: #2a2a2a;
color: #dddddd;
border: 1px solid #505050;
border-radius: 4px;
padding: 3px 12px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
outline: none;
transition: background 0.15s, border-color 0.15s, color 0.15s;
white-space: nowrap;
`;
btn.onclick = () => {
compareMode = mode;
updateButtons();
requestRender();
};
return btn;
}
const btnA = createToolbarButton("A", "Show Image A only", "A");
const btnB = createToolbarButton("B", "Show Image B only", "B");
const btnSplit = createToolbarButton("|", "Split Screen Comparison", "|");
const btnLens = createToolbarButton("O", "Circular Reveal Lens", "O");
const buttons = [btnA, btnB, btnSplit, btnLens];
buttons.forEach((b) => toolbar.appendChild(b));
container.appendChild(toolbar);
function updateButtons() {
buttons.forEach((b) => {
const isActive =
(b === btnA && compareMode === "A") ||
(b === btnB && compareMode === "B") ||
(b === btnSplit && compareMode === "|") ||
(b === btnLens && compareMode === "O");
if (isActive) {
b.style.background = "#80a1c0";
b.style.color = "#000000";
b.style.borderColor = "#a0c4e8";
} else {
b.style.background = "#2a2a2a";
b.style.color = "#dddddd";
b.style.borderColor = "#505050";
}
});
}
updateButtons();
// Canvas wrapper
const canvasWrap = document.createElement("div");
canvasWrap.style.cssText = `
position: relative;
flex: 1;
width: 100%;
height: 100%;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
background: #141414;
overflow: hidden;
cursor: default;
`;
const canvas = document.createElement("canvas");
canvas.style.cssText = `
width: 100%;
height: 100%;
display: block;
outline: none;
`;
canvasWrap.appendChild(canvas);
container.appendChild(canvasWrap);
const ctx = canvas.getContext("2d");
const compareWay = app.extensionManager?.setting?.get(
"iTools.Nodes.Compare Mode",
"makadi",
);
// ── Throttled Canvas Rendering ─────────────────────────────────────────
let renderPending = false;
function requestRender() {
if (renderPending) return;
renderPending = true;
requestAnimationFrame(() => {
renderPending = false;
render();
});
}
function render() {
const dpr = window.devicePixelRatio || 1;
const rect = canvasWrap.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (w <= 0 || h <= 0) return;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
function getBounds(img) {
if (!img || !img.naturalWidth || !img.naturalHeight) {
return { x: 0, y: 0, w: 0, h: 0 };
}
const s = Math.min(w / img.naturalWidth, h / img.naturalHeight);
const dw = img.naturalWidth * s;
const dh = img.naturalHeight * s;
return {
x: (w - dw) / 2,
y: (h - dh) / 2,
w: dw,
h: dh,
};
}
if (!imgA && !imgB) {
ctx.fillStyle = "#555555";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("No Images to Compare", w / 2, h / 2);
ctx.restore();
return;
}
// Single image fallback if one is missing
if (!imgA || !imgB) {
const single = imgA || imgB;
if (single && single.naturalWidth) {
const p = getBounds(single);
ctx.drawImage(single, p.x, p.y, p.w, p.h);
}
ctx.restore();
return;
}
// Both images available: each scales to fit the container while strictly preserving its natural aspect ratio
const compareWay =
app.extensionManager?.setting?.get?.(
"iTools.Nodes.Compare Mode",
"makadi",
) || "makadi";
const leftImg = compareWay === "makadi" ? imgA : imgB;
const rightImg = compareWay === "makadi" ? imgB : imgA;
const pLeft = getBounds(leftImg);
const pRight = getBounds(rightImg);
const imgX = Math.min(pLeft.x, pRight.x);
const imgY = Math.min(pLeft.y, pRight.y);
const imgW = Math.max(pLeft.x + pLeft.w, pRight.x + pRight.w) - imgX;
const imgH = Math.max(pLeft.y + pLeft.h, pRight.y + pRight.h) - imgY;
if (compareMode === "A") {
const pA = getBounds(imgA);
ctx.drawImage(imgA, pA.x, pA.y, pA.w, pA.h);
} else if (compareMode === "B") {
const pB = getBounds(imgB);
ctx.drawImage(imgB, pB.x, pB.y, pB.w, pB.h);
} else if (compareMode === "|") {
const splitX = isHovering
? Math.max(imgX, Math.min(imgX + imgW, mousePos.x))
: w / 2;
// Draw Left Side
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, splitX, h);
ctx.clip();
ctx.drawImage(leftImg, pLeft.x, pLeft.y, pLeft.w, pLeft.h);
ctx.restore();
// Draw Right Side
ctx.save();
ctx.beginPath();
ctx.rect(splitX, 0, w - splitX, h);
ctx.clip();
ctx.drawImage(rightImg, pRight.x, pRight.y, pRight.w, pRight.h);
ctx.restore();
// Split Divider Line
ctx.save();
ctx.beginPath();
ctx.moveTo(splitX, imgY);
ctx.lineTo(splitX, imgY + imgH);
if (useCompareStroke) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(0, 0, 0, 0.6)";
ctx.shadowBlur = 4;
ctx.stroke();
}
ctx.restore();
} else if (compareMode === "O") {
// Mode "O": Circular Reveal Lens
// 1. Draw Background Image
ctx.drawImage(leftImg, pLeft.x, pLeft.y, pLeft.w, pLeft.h);
// 2. Calculate Lens Position directly under mouse
const lensX = isHovering
? Math.max(imgX, Math.min(imgX + imgW, mousePos.x))
: w / 2;
const lensY = isHovering
? Math.max(imgY, Math.min(imgY + imgH, mousePos.y))
: h / 2;
const radius = Math.min(w, h) * 0.18;
// 3. Draw Lens Mask & Overlay Image
ctx.save();
ctx.beginPath();
ctx.arc(lensX, lensY, radius, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(rightImg, pRight.x, pRight.y, pRight.w, pRight.h);
ctx.restore();
// 4. Subtle Border around the Lens
ctx.save();
ctx.beginPath();
ctx.arc(lensX, lensY, radius, 0, Math.PI * 2);
if (useCompareStroke) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.shadowBlur = 4;
ctx.stroke();
}
ctx.restore();
}
ctx.restore();
}
// ── Mouse Handling ─────────────────────────────────────────────────────
canvasWrap.onmouseenter = (e) => {
isHovering = true;
const rect = canvasWrap.getBoundingClientRect();
mousePos.x = e.clientX - rect.left;
mousePos.y = e.clientY - rect.top;
requestRender();
};
canvasWrap.onmouseleave = () => {
isHovering = false;
requestRender();
};
canvasWrap.onmousemove = (e) => {
if (!imgA || !imgB) return;
const rect = canvasWrap.getBoundingClientRect();
mousePos.x = e.clientX - rect.left;
mousePos.y = e.clientY - rect.top;
requestRender();
};
// ── Execution Handler ──────────────────────────────────────────────────
node._itoolsOnExecuted = function (message) {
delete node.imgs;
delete node.images;
if (node.widgets) {
node.widgets = node.widgets.filter((w) => w.name === "CompareWidget");
}
const listA =
message?.itools_compare_a ||
message?.itools_compare?.a ||
(Array.isArray(message?.itools_compare) &&
message.itools_compare.length > 0
? [message.itools_compare[0]]
: null) ||
(Array.isArray(message?.images) && message.images.length > 0
? [message.images[0]]
: null);
const listB =
message?.itools_compare_b ||
message?.itools_compare?.b ||
(Array.isArray(message?.itools_compare) &&
message.itools_compare.length > 1
? [message.itools_compare[1]]
: null) ||
(Array.isArray(message?.images) && message.images.length > 1
? [message.images[1]]
: null);
if (listA?.length) {
const itemA = listA[0];
const urlA = api.apiURL(
`/view?filename=${encodeURIComponent(itemA.filename)}&type=${encodeURIComponent(
itemA.type || "temp",
)}&subfolder=${encodeURIComponent(itemA.subfolder || "")}&t=${Date.now()}`,
);
const imA = new Image();
imA.crossOrigin = "anonymous";
imA.onload = () => {
imgA = imA;
requestRender();
};
imA.src = urlA;
}
if (listB?.length) {
const itemB = listB[0];
const urlB = api.apiURL(
`/view?filename=${encodeURIComponent(itemB.filename)}&type=${encodeURIComponent(
itemB.type || "temp",
)}&subfolder=${encodeURIComponent(itemB.subfolder || "")}&t=${Date.now()}`,
);
const imB = new Image();
imB.crossOrigin = "anonymous";
imB.onload = () => {
imgB = imB;
requestRender();
};
imB.src = urlB;
}
};
// ── Add Custom DOM Widget ──────────────────────────────────────────────
node.addDOMWidget("CompareWidget", "custom", container, {
tooltip: "iTools Image Compare",
});
// ── Size Guard between Node 1 and Node 2 ───────────────────────────────
let sizeGuardDiff = null;
function measureSizeGuard() {
const wrapper = container.parentElement;
if (!wrapper || !node.size) return;
const wrapperH = wrapper.getBoundingClientRect().height;
if (wrapperH > 0 && node.size[1] > wrapperH) {
sizeGuardDiff = node.size[1] - wrapperH;
}
}
function enforceWrapperSize() {
const wrapper = container.parentElement;
if (!wrapper || !node.size) return;
const diff = sizeGuardDiff !== null ? sizeGuardDiff : 70;
const desiredWrapperH = Math.max(MIN_HEIGHT - 60, node.size[1] - diff);
const currentH = wrapper.getBoundingClientRect().height;
if (Math.abs(currentH - desiredWrapperH) > 2) {
wrapper.style.height = `${desiredWrapperH}px`;
wrapper.style.maxHeight = `${desiredWrapperH}px`;
wrapper.style.boxSizing = "border-box";
wrapper.style.overflow = "hidden";
requestRender();
}
}
const wrapperRo = new ResizeObserver(() => enforceWrapperSize());
node.onResize = function (size) {
size[0] = Math.max(MIN_WIDTH, size[0]);
size[1] = Math.max(MIN_HEIGHT, size[1]);
if (sizeGuardDiff === null) measureSizeGuard();
enforceWrapperSize();
requestRender();
};
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
origOnRemoved?.apply(this, arguments);
wrapperRo.disconnect();
};
// Initial render
setTimeout(() => {
measureSizeGuard();
if (container.parentElement) wrapperRo.observe(container.parentElement);
updateButtons();
render();
}, 50);
},
});