Files
2026-08-26 05:59:11 +03:00

690 lines
32 KiB
JavaScript

import { app } from "../../scripts/app.js";
const rgthree = {
processingMouseDown: false,
lastCanvasMouseEvent: null
};
app.registerExtension({
name: "RaykoStudio.RSLabel",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "RSLabel") return;
const origOnCreated = nodeType.prototype.onNodeCreated;
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 = {
text: "Sample Text",
fontSize: 24,
fontFamily: "",
fontColor: "#FF0000",
textAlign: "left",
backgroundColor: "transparent",
bgTransparent: true,
padding: 10,
borderRadius: 5,
borderWidth: 2,
borderColor: "#FF0000",
lineSpacing: 1.0,
letterSpacing: 0,
strokeWidth: 0,
strokeColor: "#000000"
};
this._fontsLoaded = false;
this._fontLoadError = null;
this._fontLoadPromise = this.loadFonts();
this.dialogOpen = false;
};
nodeType.prototype.loadFonts = async function() {
try {
const response = await fetch('/rayko/rs_label/get_fonts');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.availableFonts = data.font_list.map(f => f.replace(/\.(ttf|otf)$/i, ''));
if (this.availableFonts.length > 0 && !this.properties.fontFamily) {
this.properties.fontFamily = this.availableFonts[0];
}
for (const fontFile of data.font_list) {
const fontName = fontFile.replace(/\.(ttf|otf)$/i, '');
const encodedFilename = encodeURIComponent(fontFile);
const fontPath = `/rayko/rs_label/font/${encodedFilename}`;
const fontFace = new FontFace(fontName, `url(${fontPath})`);
await fontFace.load();
document.fonts.add(fontFace);
}
this._fontsLoaded = true;
this.setDirtyCanvas(true, true);
} catch (e) {
this.availableFonts = [];
this._fontsLoaded = false;
this._fontLoadError = e.message;
}
};
nodeType.prototype.drawLabel = function(ctx) {
ctx.save();
const fontSize = Math.max(this.properties.fontSize || 12, 1);
const fontFamily = this.properties.fontFamily || "Arial";
const fontColor = this.properties.fontColor || "#FF0000";
const textAlign = this.properties.textAlign || "left";
const borderRadius = Number(this.properties.borderRadius) || 0;
const borderWidth = Number(this.properties.borderWidth) || 0;
const borderColor = this.properties.borderColor || "#FF0000";
const lineSpacing = Math.max(0.5, Math.min(3.0, Number(this.properties.lineSpacing) || 1.0));
const letterSpacing = Number(this.properties.letterSpacing) || 0;
const strokeWidth = Math.max(0, Math.min(10, Number(this.properties.strokeWidth) || 0));
const strokeColor = this.properties.strokeColor || "#000000";
const effectivePadding = Math.max(Number(this.properties.padding) || 0, 5);
const halfStroke = strokeWidth / 2;
const text = this.properties.text || "";
const lines = text.split("\n");
const isFontReady = document.fonts.check(`${fontSize}px "${fontFamily}"`);
const displayFont = isFontReady ? fontFamily : "Arial";
ctx.font = `${fontSize}px "${displayFont}"`;
ctx.textBaseline = "alphabetic";
let maxWidth = 0;
const lineMetrics = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let lineWidth = 0;
if (letterSpacing !== 0 && line.length > 1) {
for (let c = 0; c < line.length; c++) {
lineWidth += ctx.measureText(line[c]).width;
if (c < line.length - 1) lineWidth += letterSpacing;
}
} else {
lineWidth = ctx.measureText(line || " ").width;
}
if (lineWidth > maxWidth) maxWidth = lineWidth;
const m = ctx.measureText(line || " ");
const ascent = m.actualBoundingBoxAscent;
const descent = m.actualBoundingBoxDescent;
const lineHeight = (ascent + descent) * lineSpacing;
lineMetrics.push({ width: lineWidth, ascent, descent, lineHeight });
}
let totalTextHeight = 0;
if (lines.length > 0) {
totalTextHeight = lineMetrics[0].ascent + halfStroke;
for (let i = 0; i < lines.length - 1; i++) {
totalTextHeight += lineMetrics[i].lineHeight;
}
totalTextHeight += lineMetrics[lines.length - 1].descent + halfStroke;
} else {
totalTextHeight = fontSize + strokeWidth;
}
const contentWidth = maxWidth + strokeWidth + effectivePadding * 2;
const contentHeight = totalTextHeight + effectivePadding * 2;
this.size[0] = Math.max(contentWidth, 100);
this.size[1] = Math.max(contentHeight, 40);
if (borderWidth > 0) {
ctx.beginPath();
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
ctx.stroke();
}
if (!this.properties.bgTransparent && this.properties.backgroundColor) {
ctx.beginPath();
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
ctx.fillStyle = this.properties.backgroundColor;
ctx.fill();
}
ctx.fillStyle = fontColor;
const innerWidth = this.size[0] - effectivePadding * 2;
let baseTextX = effectivePadding + halfStroke;
if (textAlign === "center") {
baseTextX = effectivePadding + halfStroke + (innerWidth - strokeWidth - maxWidth) / 2;
} else if (textAlign === "right") {
baseTextX = this.size[0] - effectivePadding - halfStroke - maxWidth;
}
let currentY = effectivePadding + halfStroke;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const metrics = lineMetrics[i];
let lineOffsetX = 0;
if (textAlign === "center") {
lineOffsetX = (maxWidth - metrics.width) / 2;
} else if (textAlign === "right") {
lineOffsetX = maxWidth - metrics.width;
}
const startX = baseTextX + lineOffsetX;
const drawY = currentY + metrics.ascent;
if (strokeWidth > 0) {
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.lineJoin = "round";
ctx.miterLimit = 2;
}
if (letterSpacing !== 0 && line.length > 0) {
let penX = startX;
for (let c = 0; c < line.length; c++) {
const char = line[c];
if (strokeWidth > 0) ctx.strokeText(char, penX, drawY);
ctx.fillText(char, penX, drawY);
penX += ctx.measureText(char).width + letterSpacing;
}
} else {
if (strokeWidth > 0) ctx.strokeText(line, startX, drawY);
ctx.fillText(line, startX, drawY);
}
currentY += metrics.lineHeight;
}
ctx.restore();
};
nodeType.prototype.onDblClick = function(event, pos, canvas) {
this.showSettingsDialog();
};
nodeType.prototype.showSettingsDialog = async function() {
if (this.dialogOpen) return;
if (this._fontLoadPromise) {
try { await this._fontLoadPromise; } catch (e) {}
}
this.dialogOpen = true;
this.setDirtyCanvas(true, true);
const dialog = document.createElement("div");
dialog.style.position = "fixed";
dialog.style.background = "#1a1a1a";
dialog.style.border = "2px solid #333";
dialog.style.borderRadius = "8px";
dialog.style.padding = "20px";
dialog.style.zIndex = "10000";
dialog.style.minWidth = "350px";
dialog.style.color = "#fff";
dialog.style.fontFamily = "Arial, sans-serif";
const title = document.createElement("h3");
title.textContent = "🦊 RS Label Settings";
title.style.margin = "0 0 15px 0";
title.style.color = "#fff";
title.style.cursor = "grab";
title.style.userSelect = "none";
title.style.paddingBottom = "5px";
title.style.borderBottom = "1px solid #333";
dialog.appendChild(title);
let isDragging = false;
let dragOffsetX = 0;
let 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 = e.clientX - dragOffsetX;
let newY = e.clientY - dragOffsetY;
const w = dialog.offsetWidth;
const h = dialog.offsetHeight;
newX = Math.max(10, Math.min(window.innerWidth - w - 10, newX));
newY = Math.max(10, Math.min(window.innerHeight - 20, newY));
dialog.style.left = newX + "px";
dialog.style.top = newY + "px";
});
document.addEventListener("mouseup", () => {
if (isDragging) { isDragging = false; title.style.cursor = "grab"; }
});
// FIX: Перехват History API для отслеживания навигации ComfyUI
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 API
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
document.removeEventListener("keydown", escHandler);
document.removeEventListener("visibilitychange", visibilityHandler);
window.removeEventListener("hashchange", onHashChange);
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);
// Оставляем hashchange как запасной вариант
const currentHash = window.location.hash;
const onHashChange = () => {
if (window.location.hash !== currentHash) closeDialog();
};
window.addEventListener("hashchange", onHashChange);
const createRow = (label, createControl) => {
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "center";
row.style.marginBottom = "10px";
row.style.gap = "10px";
const labelEl = document.createElement("label");
labelEl.textContent = label;
labelEl.style.minWidth = "120px";
labelEl.style.color = "#ccc";
const control = createControl();
row.appendChild(labelEl);
row.appendChild(control);
dialog.appendChild(row);
};
const textRow = document.createElement("div");
textRow.style.marginBottom = "15px";
const textLabel = document.createElement("label");
textLabel.textContent = "Text:";
textLabel.style.display = "block";
textLabel.style.marginBottom = "5px";
textLabel.style.color = "#ccc";
const textarea = document.createElement("textarea");
textarea.value = this.properties.text || "";
textarea.rows = 4;
textarea.style.width = "100%";
textarea.style.padding = "8px";
textarea.style.background = "#2a2a2a";
textarea.style.color = "#fff";
textarea.style.border = "1px solid #444";
textarea.style.borderRadius = "4px";
textarea.style.resize = "vertical";
textarea.style.fontFamily = "monospace";
textarea.addEventListener("input", (e) => {
this.properties.text = e.target.value;
this.setDirtyCanvas(true, true);
});
textRow.appendChild(textLabel);
textRow.appendChild(textarea);
dialog.appendChild(textRow);
createRow("Font Size:", () => {
const input = document.createElement("input");
input.type = "number";
input.value = this.properties.fontSize;
input.min = 1; 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.fontSize = parseInt(e.target.value);
this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Font Family:", () => {
const select = document.createElement("select");
select.style.cssText = "width:200px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
const fonts = this.availableFonts;
if (!fonts || fonts.length === 0) {
const opt = document.createElement("option");
opt.textContent = this._fontLoadError ? `Error: ${this._fontLoadError}` : "Loading...";
opt.disabled = true;
select.appendChild(opt);
} else {
fonts.forEach(font => {
const option = document.createElement("option");
option.value = font; option.textContent = font;
if (font === this.properties.fontFamily) option.selected = true;
select.appendChild(option);
});
}
select.addEventListener("change", (e) => {
this.properties.fontFamily = e.target.value;
this.setDirtyCanvas(true, true);
});
return select;
});
createRow("Font Color:", () => {
const container = document.createElement("div");
container.style.display = "flex"; container.style.alignItems = "center"; container.style.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.fontColor || "#FF0000";
const colorInput = document.createElement("input");
colorInput.type = "color"; colorInput.value = this.properties.fontColor || "#FF0000"; colorInput.style.display = "none";
preview.addEventListener("click", () => colorInput.click());
colorInput.addEventListener("input", (e) => {
this.properties.fontColor = e.target.value;
preview.style.backgroundColor = e.target.value;
this.setDirtyCanvas(true, true);
});
container.appendChild(preview); container.appendChild(colorInput);
return container;
});
createRow("Text Align:", () => {
const select = document.createElement("select");
select.style.cssText = "width:120px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
["left", "center", "right"].forEach(align => {
const option = document.createElement("option");
option.value = align; option.textContent = align;
if (align === this.properties.textAlign) option.selected = true;
select.appendChild(option);
});
select.addEventListener("change", (e) => {
this.properties.textAlign = e.target.value;
this.setDirtyCanvas(true, true);
});
return select;
});
createRow("Line Spacing:", () => {
const input = document.createElement("input");
input.type = "number";
input.value = this.properties.lineSpacing;
input.min = 0.5; input.max = 3.0; input.step = 0.1;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.lineSpacing = parseFloat(e.target.value);
this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Letter Spacing:", () => {
const input = document.createElement("input");
input.type = "number";
input.value = this.properties.letterSpacing;
input.min = -10; input.max = 50; input.step = 1;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.letterSpacing = parseInt(e.target.value);
this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Stroke Width:", () => {
const input = document.createElement("input");
input.type = "number";
input.value = this.properties.strokeWidth;
input.min = 0; input.max = 10; input.step = 1;
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
input.addEventListener("change", (e) => {
this.properties.strokeWidth = parseInt(e.target.value);
this.setDirtyCanvas(true, true);
});
return input;
});
createRow("Stroke Color:", () => {
const container = document.createElement("div");
container.style.display = "flex"; container.style.alignItems = "center"; container.style.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.strokeColor || "#000000";
const colorInput = document.createElement("input");
colorInput.type = "color"; colorInput.value = this.properties.strokeColor || "#000000"; colorInput.style.display = "none";
preview.addEventListener("click", () => colorInput.click());
colorInput.addEventListener("input", (e) => {
this.properties.strokeColor = 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;
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.display = "flex"; container.style.alignItems = "center"; container.style.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";
preview.addEventListener("click", () => {
if (this.properties.bgTransparent) return;
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;
});
createRow("Padding:", () => {
const input = document.createElement("input");
input.type = "number";
input.value = Math.max(0, this.properties.padding - 5);
input.min = 0; input.max = 95; input.step = 1;
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.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 = 50; input.step = 1;
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("Border Width:", () => {
const input = document.createElement("input");
input.type = "number";
input.value = this.properties.borderWidth;
input.min = 0; input.max = 20; input.step = 1;
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.setDirtyCanvas(true, true);
});
return input;
});
createRow("Border Color:", () => {
const container = document.createElement("div");
container.style.display = "flex"; container.style.alignItems = "center"; container.style.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;
});
const okButton = document.createElement("button");
okButton.textContent = "OK";
okButton.style.marginTop = "15px";
okButton.style.padding = "8px 20px";
okButton.style.background = "#6ABF70";
okButton.style.color = "#fff";
okButton.style.border = "none";
okButton.style.borderRadius = "4px";
okButton.style.cursor = "pointer";
okButton.style.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 dialogWidth = dialog.offsetWidth;
const dialogHeight = dialog.offsetHeight;
const margin = 10;
let leftPos = nodeRightScreenX + margin;
let topPos = nodeBottomScreenY + margin;
if (leftPos + dialogWidth > window.innerWidth - margin) {
leftPos = nodeLeftScreenX - dialogWidth - margin;
}
if (topPos + dialogHeight > window.innerHeight - margin) {
topPos = Math.max(margin, window.innerHeight - dialogHeight - margin);
}
if (leftPos < margin) {
leftPos = Math.max(margin, window.innerWidth / 2 - dialogWidth / 2);
}
dialog.style.left = leftPos + "px";
dialog.style.top = topPos + "px";
});
};
nodeType.prototype.getHelp = function() {
return `<p>The 🦊 RS Label node allows you to add a floating label to your workflow.</p>
<p>Double-click the node to open settings and customize font, colors, and layout.</p>
<ul><li><strong>Pro tip #1:</strong> Use Enter for multiline text in the textarea.</li>
<li><strong>Pro tip #2:</strong> Right-click → Pin to make clicks pass through the label.</li></ul>`;
};
nodeType.title_mode = LiteGraph.NO_TITLE;
},
async setup(app) {
const oldDrawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function(node, ctx) {
if (node.comfyClass === "RSLabel" || node.type === "RSLabel") {
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 && rgthree.processingMouseDown &&
rgthree.lastCanvasMouseEvent &&
rgthree.lastCanvasMouseEvent.type.includes("down") &&
rgthree.lastCanvasMouseEvent.which === 1) {
const isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
if (!isDoubleClick) {
nodes_list = [...nodes_list].filter((n) => {
return !(n.comfyClass === "RSLabel" && n.flags && n.flags.pinned);
});
}
}
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
};
document.addEventListener("mousedown", () => { rgthree.processingMouseDown = true; });
document.addEventListener("mouseup", () => { rgthree.processingMouseDown = false; });
}
});