Files
2026-09-07 19:44:25 +03:00

832 lines
25 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { allow_debug } from "./js_shared.js";
app.registerExtension({
name: "iTools.previewNode",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "iToolsPreviewImage") {
nodeType.prototype.onExecuted = function (message) {
delete this.imgs;
delete this.images;
if (this._itoolsOnExecuted) {
this._itoolsOnExecuted(message);
}
};
}
},
async nodeCreated(node) {
if (node.comfyClass !== "iToolsPreviewImage") {
return;
}
delete node.imgs;
delete node.images;
const MIN_WIDTH = 360;
const MIN_HEIGHT = 360;
// Prevent default Litegraph preview drawing if node.imgs gets populated
node.onDrawBackground = function (ctx) {
// Intentionally empty: all rendering is handled by the custom DOM widget
};
// State
const MAX_IMAGES = 8;
const MAX_TOAST_SHOWS = 2;
let imagesTracked = [];
let currentImageIndex = 0; // index in imagesTracked currently displayed
let isGridView = false;
let gridCells = [];
let hoveredGridIndex = -1;
let compare = false;
let compareSplitRatio = 0.5; // 0.0 to 1.0
let isHoveringCanvas = false;
let toastShownCountH = 0;
let toastShownCountPC = 0;
let toastShownCountI = 0;
// Ensure notes storage in node.properties
if (!node.properties) node.properties = {};
if (!node.properties.iToolsImageNotes)
node.properties.iToolsImageNotes = {};
function getNotes() {
if (!node.properties.iToolsImageNotes)
node.properties.iToolsImageNotes = {};
return node.properties.iToolsImageNotes;
}
function getImageKey(img) {
if (!img) return "";
if (img.dataset?.filename) return img.dataset.filename;
if (!img.src) return "";
const match = img.src.match(/filename=([^&]+)/);
return match ? decodeURIComponent(match[1]) : img.src;
}
function getNote(img) {
const key = getImageKey(img);
return (key && getNotes()[key]) || "";
}
function setNote(img, text) {
const key = getImageKey(img);
if (!key) return;
if (text) {
getNotes()[key] = text;
} else {
delete getNotes()[key];
}
}
function getActiveImage() {
if (!imagesTracked.length) return null;
return imagesTracked[currentImageIndex] || imagesTracked.at(-1);
}
// ── DOM Construction ───────────────────────────────────────────────────
const container = document.createElement("div");
container.className = "itools-preview-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;
`;
// Toolbar
const toolbar = document.createElement("div");
toolbar.className = "itools-preview-toolbar";
toolbar.style.cssText = `
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
background: #222222;
border-bottom: 1px solid #333333;
flex-shrink: 0;
box-sizing: border-box;
`;
// Button helper
function createHtmlButton(text, tooltip) {
const btn = document.createElement("button");
btn.textContent = text;
btn.title = tooltip || "";
btn.style.cssText = `
background: #2a2a2a;
color: #dddddd;
border: 1px solid #505050;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
outline: none;
transition: background 0.15s, border-color 0.15s, color 0.15s;
white-space: nowrap;
`;
btn.onmouseenter = () => {
if (!btn.dataset.active) {
btn.style.background = "#3a3a3a";
btn.style.borderColor = "#707070";
}
};
btn.onmouseleave = () => {
if (!btn.dataset.active) {
btn.style.background = "#2a2a2a";
btn.style.borderColor = "#505050";
}
};
return btn;
}
// History button
const historyBtn = createHtmlButton(
"History",
"Show all historical images in a grid",
);
historyBtn.style.borderTopRightRadius = "0";
historyBtn.style.borderBottomRightRadius = "0";
historyBtn.style.marginRight = "-1px";
// [Current] | Previous toggle button
const prevCurrentBtn = createHtmlButton(
"[Current] | Previous",
"Toggle between current and previous images",
);
prevCurrentBtn.style.borderTopLeftRadius = "0";
prevCurrentBtn.style.borderBottomLeftRadius = "0";
// Compare button
const compareBtn = createHtmlButton("|", "Toggle A/B Compare mode");
compareBtn.style.width = "24px";
compareBtn.style.textAlign = "center";
compareBtn.style.fontWeight = "bold";
// Note button
const noteBtn = createHtmlButton(
"Add Note",
"Attach or edit a note on this image",
);
noteBtn.style.marginLeft = "auto";
toolbar.appendChild(historyBtn);
toolbar.appendChild(prevCurrentBtn);
toolbar.appendChild(compareBtn);
toolbar.appendChild(noteBtn);
container.appendChild(toolbar);
// Canvas viewport 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");
// ── Resize handling ─────────────────────────────────────────────────────
let lastW = 0,
lastH = 0;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
// تجاهل القيم المتطابقة أو الصفرية (تحدث أثناء إعادة تركيب الـ DOM)
if (width === lastW && height === lastH) return;
if (width <= 0 || height <= 0) return;
lastW = width;
lastH = height;
requestRender();
});
// ── Compare settings ───────────────────────────────────────────────────
const compareWay = app.extensionManager?.setting?.get(
"iTools.Nodes.Compare Mode",
"makadi",
);
// ── Update Toolbar UI ──────────────────────────────────────────────────
function updateToolbar() {
const hasImages = imagesTracked.length > 0;
const multiple = imagesTracked.length > 1;
// Note button text
const activeImg = getActiveImage();
const currentNote = getNote(activeImg);
noteBtn.textContent = currentNote ? "Edit Note" : "Add Note";
noteBtn.disabled = !hasImages || isGridView;
noteBtn.style.opacity = hasImages && !isGridView ? "1" : "0.5";
// History button styling
if (isGridView) {
historyBtn.dataset.active = "true";
historyBtn.style.background = "#80a1c0";
historyBtn.style.color = "#000000";
historyBtn.style.borderColor = "#a0c4e8";
} else {
delete historyBtn.dataset.active;
historyBtn.style.background = "#2a2a2a";
historyBtn.style.color = "#dddddd";
historyBtn.style.borderColor = "#505050";
}
// Toggle button text
if (multiple) {
const isShowingCurrent = currentImageIndex === imagesTracked.length - 1;
prevCurrentBtn.textContent = isShowingCurrent
? "[Current] | Previous"
: "Current | [Previous]";
} else {
prevCurrentBtn.textContent = "[Current] | Previous";
}
// Compare button styling
if (compare) {
compareBtn.dataset.active = "true";
compareBtn.style.background = "#80a1c0";
compareBtn.style.color = "#000000";
compareBtn.style.borderColor = "#a0c4e8";
} else {
delete compareBtn.dataset.active;
compareBtn.style.background = "#2a2a2a";
compareBtn.style.color = "#dddddd";
compareBtn.style.borderColor = "#505050";
}
}
// ── Canvas Drawing ─────────────────────────────────────────────────────
function drawNoteBar(targetCtx, rect, text) {
if (!text || !rect) return;
const fontSize = Math.max(9, Math.min(13, Math.round(rect.w / 22)));
const padding = Math.round(fontSize / 2);
const barHeight = fontSize + padding * 2;
const barY = rect.y + rect.h - barHeight;
targetCtx.save();
targetCtx.beginPath();
targetCtx.rect(rect.x, barY, rect.w, barHeight);
targetCtx.clip();
targetCtx.fillStyle = "rgba(0, 0, 0, 0.75)";
targetCtx.fillRect(rect.x, barY, rect.w, barHeight);
targetCtx.fillStyle = "#ffffff";
targetCtx.font = `${fontSize}px monospace`;
targetCtx.textAlign = "left";
targetCtx.textBaseline = "middle";
targetCtx.fillText(text, rect.x + padding, barY + barHeight / 2);
targetCtx.restore();
}
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);
if (imagesTracked.length === 0) {
ctx.fillStyle = "#555555";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("No Image Preview", w / 2, h / 2);
ctx.restore();
return;
}
if (isGridView && imagesTracked.length > 0) {
// History Grid View
const N = imagesTracked.length;
const aspect = w / h;
let cols = Math.ceil(Math.sqrt(N * aspect));
cols = Math.max(1, Math.min(cols, N));
const rows = Math.ceil(N / cols);
const pad = 6;
const gap = 6;
const cellW = (w - pad * 2 - (cols - 1) * gap) / cols;
const cellH = (h - pad * 2 - (rows - 1) * gap) / rows;
gridCells = [];
for (let i = 0; i < N; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const cx = pad + col * (cellW + gap);
const cy = pad + row * (cellH + gap);
gridCells.push({ index: i, x: cx, y: cy, w: cellW, h: cellH });
const img = imagesTracked[i];
const isSelected = i === currentImageIndex;
const isHovered = i === hoveredGridIndex;
ctx.save();
// Card background
ctx.fillStyle = isHovered ? "#282828" : "#1c1c1c";
ctx.fillRect(cx, cy, cellW, cellH);
// Border
ctx.lineWidth = isSelected ? 2 : 1;
ctx.strokeStyle = isSelected
? "#80a1c0"
: isHovered
? "#666666"
: "#363636";
ctx.strokeRect(cx, cy, cellW, cellH);
// Draw contained image
if (img && img.naturalWidth) {
const scale = Math.min(
(cellW - 4) / img.naturalWidth,
(cellH - 4) / img.naturalHeight,
);
const iw = img.naturalWidth * scale;
const ih = img.naturalHeight * scale;
const ix = cx + (cellW - iw) / 2;
const iy = cy + (cellH - ih) / 2;
ctx.beginPath();
ctx.rect(cx + 1, cy + 1, cellW - 2, cellH - 2);
ctx.clip();
ctx.drawImage(img, ix, iy, iw, ih);
const note = getNote(img);
if (note) {
drawNoteBar(ctx, { x: ix, y: iy, w: iw, h: ih }, note);
}
}
// Number badge
ctx.restore();
ctx.save();
ctx.fillStyle = "rgba(0, 0, 0, 0.65)";
ctx.fillRect(cx + 2, cy + 2, 18, 14);
ctx.fillStyle = isSelected ? "#80a1c0" : "#cccccc";
ctx.font = "10px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${i + 1}`, cx + 11, cy + 9);
ctx.restore();
}
} else if (compare && imagesTracked.length >= 2) {
// Compare Mode
const lastTwo = imagesTracked.slice(-2);
const imgPrev = lastTwo[0];
const imgCurr = lastTwo[1];
const leftImg = compareWay === "makadi" ? imgPrev : imgCurr;
const rightImg = compareWay === "makadi" ? imgCurr : imgPrev;
// Force equal containment scale
const scaleL = Math.min(
w / (leftImg.naturalWidth || 1),
h / (leftImg.naturalHeight || 1),
);
const scaleR = Math.min(
w / (rightImg.naturalWidth || 1),
h / (rightImg.naturalHeight || 1),
);
const scale = Math.min(scaleL, scaleR);
const imgW = Math.max(
leftImg.naturalWidth * scale,
rightImg.naturalWidth * scale,
);
const imgH = Math.max(
leftImg.naturalHeight * scale,
rightImg.naturalHeight * scale,
);
const imgX = (w - imgW) / 2;
const imgY = (h - imgH) / 2;
const splitX = isHoveringCanvas
? Math.max(
imgX,
Math.min(imgX + imgW, imgX + imgW * compareSplitRatio),
)
: w / 2;
// Draw Left
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, splitX, h);
ctx.clip();
const lW = leftImg.naturalWidth * scale;
const lH = leftImg.naturalHeight * scale;
const lX = (w - lW) / 2;
const lY = (h - lH) / 2;
ctx.drawImage(leftImg, lX, lY, lW, lH);
drawNoteBar(ctx, { x: lX, y: lY, w: lW, h: lH }, getNote(leftImg));
ctx.restore();
// Draw Right
ctx.save();
ctx.beginPath();
ctx.rect(splitX, 0, w - splitX, h);
ctx.clip();
const rW = rightImg.naturalWidth * scale;
const rH = rightImg.naturalHeight * scale;
const rX = (w - rW) / 2;
const rY = (h - rH) / 2;
ctx.drawImage(rightImg, rX, rY, rW, rH);
drawNoteBar(ctx, { x: rX, y: rY, w: rW, h: rH }, getNote(rightImg));
ctx.restore();
// Split Divider Line
ctx.save();
ctx.beginPath();
ctx.moveTo(splitX, imgY);
ctx.lineTo(splitX, imgY + imgH);
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 {
// Single Image Mode
const img = getActiveImage();
if (img && img.naturalWidth) {
const scale = Math.min(w / img.naturalWidth, h / img.naturalHeight);
const drawW = img.naturalWidth * scale;
const drawH = img.naturalHeight * scale;
const drawX = (w - drawW) / 2;
const drawY = (h - drawH) / 2;
ctx.drawImage(img, drawX, drawY, drawW, drawH);
drawNoteBar(
ctx,
{ x: drawX, y: drawY, w: drawW, h: drawH },
getNote(img),
);
}
}
ctx.restore();
}
// ── Mouse Interaction ──────────────────────────────────────────────────
canvasWrap.onclick = (e) => {
if (!isGridView) return;
const rect = canvasWrap.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
for (const cell of gridCells) {
if (
mx >= cell.x &&
mx <= cell.x + cell.w &&
my >= cell.y &&
my <= cell.y + cell.h
) {
currentImageIndex = cell.index;
isGridView = false;
hoveredGridIndex = -1;
canvasWrap.style.cursor = "default";
updateToolbar();
requestRender();
return;
}
}
};
canvasWrap.onmouseenter = () => {
isHoveringCanvas = true;
};
canvasWrap.onmouseleave = () => {
isHoveringCanvas = false;
if (isGridView) {
if (hoveredGridIndex !== -1) {
hoveredGridIndex = -1;
canvasWrap.style.cursor = "default";
requestRender();
}
return;
}
compareSplitRatio = 0.5;
requestRender();
};
canvasWrap.onmousemove = (e) => {
const rect = canvasWrap.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (isGridView) {
let found = -1;
for (const cell of gridCells) {
if (
mx >= cell.x &&
mx <= cell.x + cell.w &&
my >= cell.y &&
my <= cell.y + cell.h
) {
found = cell.index;
break;
}
}
if (found !== hoveredGridIndex) {
hoveredGridIndex = found;
canvasWrap.style.cursor = found !== -1 ? "pointer" : "default";
requestRender();
}
return;
}
canvasWrap.style.cursor = "default";
if (!compare || imagesTracked.length < 2) return;
compareSplitRatio = Math.max(0, Math.min(1, mx / rect.width));
requestRender();
};
// ── Button Handlers ────────────────────────────────────────────────────
historyBtn.onclick = () => {
if (compare) {
compare = false;
}
if (imagesTracked.length === 0) {
if (toastShownCountH < MAX_TOAST_SHOWS) {
app.extensionManager.toast.add({
severity: "info",
summary: "iTools!",
detail: "No images in this node history",
life: 2000,
});
toastShownCountH++;
}
return;
}
if (imagesTracked.length === 1) {
if (toastShownCountH < MAX_TOAST_SHOWS) {
app.extensionManager.toast.add({
severity: "info",
summary: "iTools!",
detail: "Only this image exists in this node history",
life: 2000,
});
toastShownCountH++;
}
return;
}
// Toggle grid view
isGridView = !isGridView;
hoveredGridIndex = -1;
canvasWrap.style.cursor = "default";
updateToolbar();
requestRender();
};
prevCurrentBtn.onclick = () => {
if (compare) {
compare = false;
}
if (isGridView) {
isGridView = false;
hoveredGridIndex = -1;
canvasWrap.style.cursor = "default";
}
if (imagesTracked.length < 2) {
if (toastShownCountPC < MAX_TOAST_SHOWS) {
app.extensionManager.toast.add({
severity: "info",
summary: "iTools!",
detail: "You must execute this node at least twice",
life: 2000,
});
toastShownCountPC++;
}
return;
}
// Toggle between current (last) and previous (second to last)
const lastIndex = imagesTracked.length - 1;
const prevIndex = imagesTracked.length - 2;
currentImageIndex =
currentImageIndex === lastIndex ? prevIndex : lastIndex;
updateToolbar();
requestRender();
};
compareBtn.onclick = () => {
if (isGridView) {
isGridView = false;
hoveredGridIndex = -1;
canvasWrap.style.cursor = "default";
}
if (imagesTracked.length < 2) {
if (toastShownCountI < MAX_TOAST_SHOWS) {
app.extensionManager.toast.add({
severity: "info",
summary: "iTools!",
detail: "You must execute this node at least twice",
life: 2000,
});
toastShownCountI++;
}
return;
}
compare = !compare;
currentImageIndex = imagesTracked.length - 1;
compareSplitRatio = 0.5;
updateToolbar();
requestRender();
};
noteBtn.onclick = () => {
const img = getActiveImage();
if (!img) return;
const currentNote = getNote(img);
app.extensionManager?.dialog
?.prompt({
title: "iTools Preview Image",
message: "Note for this image (leave empty to remove)",
defaultValue: currentNote,
default: currentNote,
})
?.then((value) => {
if (value === null || value === undefined) return;
setNote(img, value.trim());
updateToolbar();
requestRender();
});
};
// ── Image Handling ─────────────────────────────────────────────────────
function pushTrackedImage(newImg) {
if (!newImg || !newImg.naturalWidth) return;
const newKey = getImageKey(newImg);
const exists = imagesTracked.some((img) => getImageKey(img) === newKey);
if (!exists) {
imagesTracked.push(newImg);
if (imagesTracked.length > MAX_IMAGES) {
imagesTracked.shift();
}
}
currentImageIndex = imagesTracked.length - 1;
isGridView = false;
updateToolbar();
requestRender();
}
node._itoolsOnExecuted = function (message) {
delete node.imgs;
delete node.images;
if (node.widgets) {
node.widgets = node.widgets.filter((w) => w.name === "PreviewWidget");
}
const imagesList = message?.itools_preview || message?.images;
if (!imagesList?.length) return;
for (const item of imagesList) {
const url = api.apiURL(
`/view?filename=${encodeURIComponent(item.filename)}&type=${encodeURIComponent(
item.type || "temp",
)}&subfolder=${encodeURIComponent(item.subfolder || "")}&t=${Date.now()}`,
);
const img = new Image();
img.crossOrigin = "anonymous";
img.dataset.filename = item.filename;
img.onload = () => {
pushTrackedImage(img);
};
img.src = url;
}
};
// ── Add Custom DOM Widget ──────────────────────────────────────────────
const widget = node.addDOMWidget("PreviewWidget", "custom", container, {
tooltip: "iTools Image Preview",
getValue: () => ({
notes: node.properties?.iToolsImageNotes || {},
}),
setValue: (v) => {
if (v?.notes) {
node.properties.iToolsImageNotes = Object.assign(
{},
node.properties.iToolsImageNotes,
v.notes,
);
updateToolbar();
requestRender();
}
},
});
// ── 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) {
sizeGuardDiff = node.size[1] - wrapperH;
}
}
function enforceWrapperSize() {
const wrapper = container.parentElement;
if (!wrapper || !node.size || sizeGuardDiff === null) return;
const desiredWrapperH = Math.max(MIN_HEIGHT - 60, node.size[1] - sizeGuardDiff);
const currentH = wrapper.getBoundingClientRect().height;
if (Math.abs(currentH - desiredWrapperH) > 2) {
wrapper.style.height = `${desiredWrapperH}px`;
wrapper.style.maxHeight = `${desiredWrapperH}px`;
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);
ro.disconnect();
wrapperRo.disconnect();
};
// Initial render
setTimeout(() => {
measureSizeGuard();
if (container.parentElement) wrapperRo.observe(container.parentElement);
updateToolbar();
render();
}, 50);
},
});