Files
Raykosan-ComfyUI_RaykoStudio/web/rs_image_label.js
T
2026-08-26 19:25:47 +03:00

624 lines
28 KiB
JavaScript

import { app } from "../../scripts/app.js";
const rsState = {
processingMouseDown: false,
lastCanvasMouseEvent: null
};
app.registerExtension({
name: "RaykoStudio.RSImageLabel",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "RSImageLabel") return;
const origOnCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype._rsDnDHandler = function(e) {
const canvas = LGraphCanvas.active_canvas;
if (!canvas || !this.graph) return false;
const point = canvas.convertEventToCanvasOffset(e);
return point[0] >= this.pos[0] &&
point[0] <= this.pos[0] + this.size[0] &&
point[1] >= this.pos[1] &&
point[1] <= this.pos[1] + this.size[1];
};
nodeType.prototype.onNodeCreated = function() {
if (origOnCreated) origOnCreated.apply(this, arguments);
this.title = "";
this.resizable = false;
this.collapsable = false;
this.flags = {};
this.shape = LiteGraph.CUSTOM_SHAPE;
this.color = "#fff0";
this.bgcolor = "#fff0";
this.shadow_color = "transparent";
this.onDrawBackground = function(ctx) {};
this.properties = {
size: 100,
padding: 5,
borderWidth: 2,
borderColor: "#FF0000",
borderRadius: 5,
backgroundColor: "transparent",
bgTransparent: true,
syncImageRadius: true,
imageRadius: 0,
embeddedData: ""
};
const pathWidget = this.addWidget("text", "_image_path", "", () => {});
pathWidget.hidden = true;
pathWidget.serializeValue = () => pathWidget.value || "";
this.cachedImage = null;
this.imageLoaded = false;
this.dialogOpen = false;
// Приоритет: embeddedData -> путь к файлу
if (this.properties.embeddedData) {
this.loadImage(this.properties.embeddedData);
} else if (pathWidget.value) {
this.loadImage(this.getImageUrl());
} else {
this.updateNodeSize();
}
this._boundDragOver = (e) => {
if (this._rsDnDHandler(e)) e.preventDefault();
};
this._boundDrop = (e) => {
if (this._rsDnDHandler(e)) {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer?.files;
if (files && files.length > 0 && files[0].type.startsWith("image/")) {
this.uploadImage(files[0]);
}
}
};
const canvas = app.canvas?.canvas;
if (canvas) {
canvas.addEventListener("dragover", this._boundDragOver, { capture: true });
canvas.addEventListener("drop", this._boundDrop, { capture: true });
}
};
nodeType.prototype.onConfigure = function(info) {
if (info && info.widgets_values) {
const widgetIndex = this.widgets?.findIndex(w => w.name === "_image_path");
if (widgetIndex !== -1 && info.widgets_values[widgetIndex]) {
this.widgets[widgetIndex].value = info.widgets_values[widgetIndex];
}
}
// Восстановление картинки после загрузки JSON
if (this.properties.embeddedData) {
this.loadImage(this.properties.embeddedData);
} else if (this.widgets?.find(w => w.name === "_image_path")?.value) {
this.loadImage(this.getImageUrl());
}
};
nodeType.prototype.onSerialize = function(o) {
if (!o.properties) o.properties = {};
for (const key in this.properties) {
o.properties[key] = this.properties[key];
}
const pathWidget = this.widgets?.find(w => w.name === "_image_path");
if (pathWidget) {
if (!o.widgets_values) o.widgets_values = [];
const index = this.widgets.indexOf(pathWidget);
if (index !== -1) {
while (o.widgets_values.length <= index) o.widgets_values.push(null);
o.widgets_values[index] = pathWidget.value;
}
}
};
nodeType.prototype.onRemoved = function() {
const canvas = app.canvas?.canvas;
if (canvas && this._boundDragOver && this._boundDrop) {
canvas.removeEventListener("dragover", this._boundDragOver, { capture: true });
canvas.removeEventListener("drop", this._boundDrop, { capture: true });
}
};
nodeType.prototype.getImageUrl = function() {
const pathWidget = this.widgets?.find(w => w.name === "_image_path");
if (!pathWidget || !pathWidget.value) return null;
const parts = pathWidget.value.split(" ");
const filename = parts[0];
const subfolder = parts.slice(1).join(" ") || "";
return `/rayko/rs_image_label/get_image?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}&type=temp`;
};
nodeType.prototype.updateEmbeddedData = function() {
if (!this.imageLoaded || !this.cachedImage) return;
const img = this.cachedImage;
let w = img.width;
let h = img.height;
const maxEmbedSize = 512;
if (w > maxEmbedSize || h > maxEmbedSize) {
const aspect = w / h;
if (aspect >= 1) {
w = maxEmbedSize;
h = Math.round(maxEmbedSize / aspect);
} else {
h = maxEmbedSize;
w = Math.round(maxEmbedSize * aspect);
}
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, w, h);
this.properties.embeddedData = canvas.toDataURL("image/webp", 0.9);
};
nodeType.prototype.loadImage = function(src) {
if (!src) {
this.cachedImage = null;
this.imageLoaded = false;
this.updateNodeSize();
this.setDirtyCanvas(true, true);
return;
}
const img = new Image();
// FIX: Восстановили crossOrigin для корректной работы toDataURL и CORS
img.crossOrigin = "Anonymous";
img.onload = () => {
this.cachedImage = img;
this.imageLoaded = true;
// Обновляем embeddedData только если это не оно само (избегаем рекурсии и лишних операций)
if (!src.startsWith("data:")) {
this.updateEmbeddedData();
}
this.updateNodeSize();
this.setDirtyCanvas(true, true);
};
img.onerror = () => {
console.warn("[RS Image Label] Failed to load image:", src);
this.cachedImage = null;
this.imageLoaded = false;
this.updateNodeSize();
this.setDirtyCanvas(true, true);
};
img.src = src;
};
nodeType.prototype.uploadImage = async function(file) {
const pathWidget = this.widgets?.find(w => w.name === "_image_path");
if (!pathWidget) return;
const formData = new FormData();
formData.append("image", file);
formData.append("subfolder", "");
formData.append("type", "temp");
formData.append("overwrite", "true");
try {
const resp = await fetch("/upload/image", { method: "POST", body: formData });
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
const data = await resp.json();
if (data.name) {
const fullPath = data.subfolder ? `${data.name} ${data.subfolder}` : data.name;
pathWidget.value = fullPath;
this.loadImage(this.getImageUrl());
}
} catch (e) {
console.error("[RS Label Image] Upload failed:", e);
}
};
nodeType.prototype.updateNodeSize = function() {
const internalPadding = Math.max(Number(this.properties.padding) || 0, 5);
const bw = Number(this.properties.borderWidth) || 0;
let displayW = 100, displayH = 100;
if (this.imageLoaded && this.cachedImage) {
const maxSize = Math.max(16, Math.min(200, Number(this.properties.size) || 100));
const aspect = this.cachedImage.width / this.cachedImage.height;
if (aspect >= 1) {
displayW = maxSize;
displayH = maxSize / aspect;
} else {
displayH = maxSize;
displayW = maxSize * aspect;
}
}
this.size[0] = displayW + internalPadding * 2 + bw * 2;
this.size[1] = displayH + internalPadding * 2 + bw * 2;
};
nodeType.prototype.drawLabel = function(ctx) {
ctx.save();
const internalPadding = Math.max(Number(this.properties.padding) || 0, 5);
const bw = Number(this.properties.borderWidth) || 0;
const br = Number(this.properties.borderRadius) || 0;
const bc = this.properties.borderColor || "#FF0000";
const bg = this.properties.backgroundColor;
if (!this.properties.bgTransparent && bg && bg !== "transparent") {
ctx.beginPath();
ctx.roundRect(0, 0, this.size[0], this.size[1], [br]);
ctx.fillStyle = bg;
ctx.fill();
}
if (bw > 0) {
ctx.beginPath();
ctx.roundRect(0, 0, this.size[0], this.size[1], [br]);
ctx.strokeStyle = bc;
ctx.lineWidth = bw;
ctx.stroke();
}
if (this.imageLoaded && this.cachedImage) {
const maxSize = Math.max(16, Math.min(200, Number(this.properties.size) || 100));
const aspect = this.cachedImage.width / this.cachedImage.height;
let drawW, drawH;
if (aspect >= 1) {
drawW = maxSize;
drawH = maxSize / aspect;
} else {
drawH = maxSize;
drawW = maxSize * aspect;
}
const x = bw + internalPadding;
const y = bw + internalPadding;
let imageRadius = 0;
if (this.properties.syncImageRadius) {
imageRadius = Math.max(0, br - bw - internalPadding);
} else {
imageRadius = Number(this.properties.imageRadius) || 0;
}
const maxPossibleRadius = Math.min(drawW, drawH) / 2;
imageRadius = Math.min(imageRadius, maxPossibleRadius);
if (imageRadius > 0) {
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, drawW, drawH, [imageRadius]);
ctx.clip();
ctx.drawImage(this.cachedImage, x, y, drawW, drawH);
ctx.restore();
} else {
ctx.drawImage(this.cachedImage, x, y, drawW, drawH);
}
}
ctx.restore();
};
nodeType.prototype.onDblClick = function(event, pos, canvas) {
this.showSettingsDialog();
};
nodeType.prototype.showSettingsDialog = async function() {
if (this.dialogOpen) return;
this.dialogOpen = true;
this.setDirtyCanvas(true, true);
const dialog = document.createElement("div");
dialog.style.cssText = "position:fixed;background:#1a1a1a;border:2px solid #333;border-radius:8px;padding:20px;z-index:10000;min-width:350px;color:#fff;font-family:Arial,sans-serif;";
const title = document.createElement("h3");
title.textContent = "🦊 RS Image Label Settings";
title.style.cssText = "margin:0 0 15px 0;color:#fff;cursor:grab;user-select:none;padding-bottom:5px;border-bottom:1px solid #333;";
dialog.appendChild(title);
let isDragging = false, dragOffsetX = 0, dragOffsetY = 0;
title.addEventListener("mousedown", (e) => {
isDragging = true;
const rect = dialog.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
title.style.cursor = "grabbing";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
let newX = Math.max(10, Math.min(window.innerWidth - dialog.offsetWidth - 10, e.clientX - dragOffsetX));
let newY = Math.max(10, Math.min(window.innerHeight - 20, e.clientY - dragOffsetY));
dialog.style.left = newX + "px";
dialog.style.top = newY + "px";
});
document.addEventListener("mouseup", () => {
if (isDragging) { isDragging = false; title.style.cursor = "grab"; }
});
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
const onNavigate = () => closeDialog();
history.pushState = function(...args) { originalPushState.apply(this, args); onNavigate(); };
history.replaceState = function(...args) { originalReplaceState.apply(this, args); onNavigate(); };
const closeDialog = () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
document.removeEventListener("keydown", escHandler);
document.removeEventListener("visibilitychange", visibilityHandler);
if (dialog.parentNode) document.body.removeChild(dialog);
this.dialogOpen = false;
this.setDirtyCanvas(true, true);
};
const escHandler = (e) => { if (e.key === "Escape") closeDialog(); };
document.addEventListener("keydown", escHandler);
const visibilityHandler = () => { if (document.hidden) closeDialog(); };
document.addEventListener("visibilitychange", visibilityHandler);
const createRow = (label, createControl) => {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;margin-bottom:10px;gap:10px;";
const labelEl = document.createElement("label");
labelEl.textContent = label;
labelEl.style.cssText = "min-width:120px;color:#ccc;";
const control = createControl();
row.appendChild(labelEl);
row.appendChild(control);
dialog.appendChild(row);
};
const uploadRow = document.createElement("div");
uploadRow.style.cssText = "margin-bottom:15px;text-align:center;";
const uploadBtn = document.createElement("button");
uploadBtn.textContent = "📁 Load Image";
uploadBtn.style.cssText = "width:100%;padding:8px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;cursor:pointer;";
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/png,image/jpeg,image/webp";
fileInput.style.display = "none";
fileInput.addEventListener("change", (e) => {
if (e.target.files.length > 0) this.uploadImage(e.target.files[0]);
});
uploadBtn.addEventListener("click", () => fileInput.click());
uploadRow.appendChild(uploadBtn);
uploadRow.appendChild(fileInput);
dialog.appendChild(uploadRow);
createRow("Size (max side):", () => {
const input = document.createElement("input");
input.type = "number"; input.value = this.properties.size;
input.min = 16; input.max = 200;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.size = parseInt(e.target.value);
this.updateNodeSize(); this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Padding:", () => {
const input = document.createElement("input");
input.type = "number"; input.value = Math.max(0, this.properties.padding - 5);
input.min = 0; input.max = 45;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.padding = Math.max(5, parseInt(e.target.value) + 5);
this.updateNodeSize(); this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Border Width:", () => {
const input = document.createElement("input");
input.type = "number"; input.value = this.properties.borderWidth;
input.min = 0; input.max = 10;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.borderWidth = parseInt(e.target.value);
this.updateNodeSize(); this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Border Radius:", () => {
const input = document.createElement("input");
input.type = "number"; input.value = this.properties.borderRadius;
input.min = 0; input.max = 120;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.borderRadius = parseInt(e.target.value);
this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Sync Image Radius:", () => {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this.properties.syncImageRadius !== false;
checkbox.addEventListener("change", (e) => {
this.properties.syncImageRadius = e.target.checked;
this.setDirtyCanvas(true, true);
const imgRadiusInput = document.getElementById("rs-img-radius-input");
if (imgRadiusInput) {
imgRadiusInput.disabled = this.properties.syncImageRadius;
imgRadiusInput.style.opacity = this.properties.syncImageRadius ? "0.3" : "1";
}
});
return checkbox;
});
createRow("Image Radius:", () => {
const input = document.createElement("input");
input.id = "rs-img-radius-input";
input.type = "number";
input.value = this.properties.imageRadius;
input.min = 0; input.max = 100;
input.disabled = this.properties.syncImageRadius;
input.style.cssText = `width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;opacity:${this.properties.syncImageRadius ? "0.3" : "1"};`;
input.addEventListener("change", (e) => {
this.properties.imageRadius = parseInt(e.target.value);
this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Border Color:", () => {
const container = document.createElement("div");
container.style.cssText = "display:flex;align-items:center;gap:8px;";
const preview = document.createElement("div");
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
preview.style.backgroundColor = this.properties.borderColor || "#FF0000";
const colorInput = document.createElement("input");
colorInput.type = "color"; colorInput.value = this.properties.borderColor || "#FF0000"; colorInput.style.display = "none";
preview.addEventListener("click", () => colorInput.click());
colorInput.addEventListener("input", (e) => {
this.properties.borderColor = e.target.value;
preview.style.backgroundColor = e.target.value;
this.setDirtyCanvas(true, true);
});
container.appendChild(preview); container.appendChild(colorInput);
return container;
});
createRow("BG Transparent:", () => {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this.properties.bgTransparent !== false;
checkbox.addEventListener("change", (e) => {
this.properties.bgTransparent = e.target.checked;
this.setDirtyCanvas(true, true);
});
return checkbox;
});
createRow("BG Color:", () => {
const container = document.createElement("div");
container.style.cssText = "display:flex;align-items:center;gap:8px;";
const preview = document.createElement("div");
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
preview.style.backgroundColor = this.properties.backgroundColor || "#ffffff";
const colorInput = document.createElement("input");
colorInput.type = "color"; colorInput.value = this.properties.backgroundColor || "#ffffff"; colorInput.style.display = "none";
const updatePreviewState = () => {
if (this.properties.bgTransparent) {
preview.style.opacity = "0.3";
preview.style.cursor = "not-allowed";
} else {
preview.style.opacity = "1";
preview.style.cursor = "pointer";
}
};
updatePreviewState();
preview.addEventListener("click", () => {
if (!this.properties.bgTransparent) colorInput.click();
});
colorInput.addEventListener("input", (e) => {
this.properties.backgroundColor = e.target.value;
preview.style.backgroundColor = e.target.value;
this.setDirtyCanvas(true, true);
});
container.appendChild(preview); container.appendChild(colorInput);
return container;
});
const okButton = document.createElement("button");
okButton.textContent = "OK";
okButton.style.cssText = "margin-top:15px;padding:8px 20px;background:#6ABF70;color:#fff;border:none;border-radius:4px;cursor:pointer;width:100%;";
okButton.addEventListener("click", closeDialog);
dialog.appendChild(okButton);
document.body.appendChild(dialog);
requestAnimationFrame(() => {
const canvas = LGraphCanvas.active_canvas?.canvas;
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
const ds = LGraphCanvas.active_canvas.ds;
const nodeRightScreenX = canvasRect.left + (this.pos[0] + this.size[0] + ds.offset[0]) * ds.scale;
const nodeLeftScreenX = canvasRect.left + (this.pos[0] + ds.offset[0]) * ds.scale;
const nodeBottomScreenY = canvasRect.top + (this.pos[1] + this.size[1] + ds.offset[1]) * ds.scale;
const margin = 10;
let leftPos = nodeRightScreenX + margin;
let topPos = nodeBottomScreenY + margin;
if (leftPos + dialog.offsetWidth > window.innerWidth - margin) {
leftPos = nodeLeftScreenX - dialog.offsetWidth - margin;
}
if (topPos + dialog.offsetHeight > window.innerHeight - margin) {
topPos = Math.max(margin, window.innerHeight - dialog.offsetHeight - margin);
}
if (leftPos < margin) {
leftPos = Math.max(margin, window.innerWidth / 2 - dialog.offsetWidth / 2);
}
dialog.style.left = leftPos + "px";
dialog.style.top = topPos + "px";
});
};
nodeType.prototype.getHelp = function() {
return `<p>The 🦊 RS Image Label node displays an image as a floating transparent label.</p>
<p><strong>Double-click</strong> to open settings. <strong>Drag & drop</strong> an image onto the node to upload.</p>`;
};
nodeType.title_mode = LiteGraph.NO_TITLE;
},
async setup(app) {
const oldDrawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function(node, ctx) {
if (node.comfyClass === "RSImageLabel" || node.type === "RSImageLabel") {
node.color = "#fff0"; node.bgcolor = "#fff0"; node.shadow_color = "transparent";
if (node.drawLabel) node.drawLabel(ctx);
return;
}
return oldDrawNode.apply(this, arguments);
};
const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
LGraph.prototype.getNodeOnPos = function(x, y, nodes_list) {
if (nodes_list && rsState.processingMouseDown &&
rsState.lastCanvasMouseEvent &&
rsState.lastCanvasMouseEvent.type.includes("down") &&
rsState.lastCanvasMouseEvent.which === 1) {
const isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
if (!isDoubleClick) {
nodes_list = [...nodes_list].filter((n) => {
return !(n.comfyClass === "RSImageLabel" && n.flags && n.flags.pinned);
});
}
}
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
};
document.addEventListener("mousedown", () => { rsState.processingMouseDown = true; });
document.addEventListener("mouseup", () => { rsState.processingMouseDown = false; });
}
});