import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "RaykoStudio.ImageSelector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "RS Image Selector") return;
const MIN_WIDTH = 300;
const MIN_HEIGHT = 400;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
this.images = [];
this.selectedIndices = new Set();
this.imagePaths = [];
this.userResized = false;
this.heartbeatInterval = null;
this.setSize([MIN_WIDTH, MIN_HEIGHT]);
this.min_size = [MIN_WIDTH, MIN_HEIGHT];
this.buttons = [
{ label: "➕ SELECT ALL", key: "select_all", color: "#2196F3", hover: false, row: 0, col: 0 },
{ label: "✔️ ACCEPT", key: "accept", color: "#4CAF50", hover: false, row: 0, col: 1 },
{ label: "⭕ DESELECT ALL", key: "deselect_all", color: "#9E9E9E", hover: false, row: 1, col: 0 },
{ label: "❌ CANCEL", key: "cancel", color: "#dc3545", hover: false, row: 1, col: 1 }
];
this.onVisibilityChange = () => {
if (document.visibilityState === "visible") {
this.setDirtyCanvas(true);
}
};
document.addEventListener("visibilitychange", this.onVisibilityChange);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (onRemoved) onRemoved.apply(this, arguments);
if (this.onVisibilityChange) {
document.removeEventListener("visibilitychange", this.onVisibilityChange);
}
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
console.log(`[ImSELECT 🦊] Node ${this.id} removed, sending cleanup signal...`);
fetch("/rayko/imselect/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(this.id) })
}).catch(err => {
console.error("[ImSELECT 🦊] Cleanup signal failed: ", err);
});
};
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
if (this.size[0] < MIN_WIDTH) this.size[0] = MIN_WIDTH;
if (this.size[1] < MIN_HEIGHT) this.size[1] = MIN_HEIGHT;
this.userResized = true;
};
nodeType.prototype.startHeartbeat = function() {
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
this.heartbeatInterval = setInterval(async () => {
try {
await api.fetchApi("/rayko/imselect/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(this.id) })
});
} catch (e) {
console.log("[ImSELECT 🦊] Heartbeat failed");
}
}, 5000);
};
nodeType.prototype.stopHeartbeat = function() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
};
nodeType.prototype.autoSize = function(imageCount) {
const CONFIG = {
minImageSize: 120,
maxImageSize: 200,
targetImageSize: 150,
minCols: 2,
maxCols: 6,
margin: 12,
topBarH: 10,
bottomBarH: 85,
minWidth: MIN_WIDTH,
maxWidth: 1200,
minHeight: MIN_HEIGHT,
maxHeight: 900
};
let cols = Math.ceil(Math.sqrt(imageCount * (CONFIG.targetImageSize / CONFIG.targetImageSize)));
cols = Math.max(CONFIG.minCols, Math.min(CONFIG.maxCols, cols));
if (imageCount <= 2) cols = 2;
else if (imageCount <= 6) cols = 3;
else if (imageCount <= 12) cols = 4;
else if (imageCount <= 20) cols = 5;
else cols = 6;
const rows = Math.ceil(imageCount / cols);
const contentWidth = (cols * CONFIG.targetImageSize) + ((cols - 1) * CONFIG.margin);
const contentHeight = (rows * CONFIG.targetImageSize) + ((rows - 1) * CONFIG.margin);
const targetWidth = Math.max(CONFIG.minWidth, Math.min(CONFIG.maxWidth, contentWidth + (CONFIG.margin * 2)));
const targetHeight = Math.max(CONFIG.minHeight, Math.min(CONFIG.maxHeight,
contentHeight + CONFIG.topBarH + CONFIG.bottomBarH));
const shouldResize =
this.size[0] < targetWidth - 50 ||
this.size[1] < targetHeight - 50;
if (shouldResize && !this.userResized) {
this.setSize([
Math.max(this.size[0], targetWidth),
Math.max(this.size[1], targetHeight)
]);
}
return { cols, rows, targetWidth, targetHeight };
};
nodeType.prototype.getNodeScreenPosition = function() {
const canvasEl = app.canvas?.canvas || document.querySelector('canvas');
if (!canvasEl || !this.pos) {
return { x: 250, y: 200 };
}
const canvasRect = canvasEl.getBoundingClientRect();
const ds = app.canvas.ds;
return {
x: canvasRect.left + ((this.pos[0] + ds.offset[0]) * ds.scale),
y: canvasRect.top + ((this.pos[1] + ds.offset[1]) * ds.scale)
};
};
nodeType.prototype.showWarningPopup = function() {
const existingPopup = document.querySelector('.imselect-warning-popup');
if (existingPopup) existingPopup.remove();
const popup = document.createElement('div');
popup.className = 'imselect-warning-popup';
popup.style.cssText = `
position: fixed;
background: #1a1a1a;
border: 2px solid #dc3545;
border-radius: 8px;
padding: 20px;
z-index: 10001;
box-shadow: 0 4px 30px rgba(0,0,0,0.7);
max-width: 450px;
font-family: 'Segoe UI', Roboto, sans-serif;
`;
const nodeScreenPos = this.getNodeScreenPosition();
popup.style.left = (nodeScreenPos.x + 50) + 'px';
popup.style.top = (nodeScreenPos.y + 100) + 'px';
popup.innerHTML = `
⚠️ WARNING / ВНИМАНИЕ
No images selected.
Please select at least one image, or press
❌ CANCEL
Не выбрано ни одно изображение.
Выберите хотя бы одно изображение,
или нажмите кнопку
❌ CANCEL
`;
document.body.appendChild(popup);
document.getElementById('imselect-warning-ok').addEventListener('click', () => {
popup.remove();
});
setTimeout(() => {
const closeHandler = (e) => {
if (!popup.contains(e.target)) {
popup.remove();
document.removeEventListener('mousedown', closeHandler);
}
};
document.addEventListener('mousedown', closeHandler);
}, 100);
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) onDrawForeground.apply(this, arguments);
if (!this.imagePaths.length) {
ctx.fillStyle = "#888";
ctx.font = "italic 13px 'Segoe UI', Roboto, sans-serif";
ctx.textAlign = "center";
const titleBarHeight = LiteGraph.NODE_TITLE_HEIGHT || 30;
const padding = 10;
const footerHeight = 65;
let widgetsTotalHeight = 0;
if (this.widgets) {
for (const w of this.widgets) {
if (!w.hidden && w.type !== "button") {
widgetsTotalHeight += (w.computeSize ? w.computeSize(this.size[0])[1] : 20);
}
}
}
const startY = titleBarHeight + widgetsTotalHeight + padding;
const availableHeight = this.size[1] - startY - footerHeight - padding;
ctx.fillText("Ready for images...", this.size[0] / 2, startY + availableHeight / 2);
return;
}
const titleBarHeight = LiteGraph.NODE_TITLE_HEIGHT || 30;
const padding = 10;
const footerHeight = 65;
const btnH = 28;
const btnGap = 5;
const btnY = this.size[1] - 70;
let widgetsTotalHeight = 0;
if (this.widgets) {
for (const w of this.widgets) {
if (!w.hidden && w.type !== "button") {
widgetsTotalHeight += (w.computeSize ? w.computeSize(this.size[0])[1] : 20);
}
}
}
const startY = titleBarHeight + widgetsTotalHeight + padding;
const availableHeight = this.size[1] - startY - footerHeight - padding;
const availableWidth = this.size[0] - (padding * 2);
const contentX = padding;
const contentY = startY;
const contentW = availableWidth;
const contentH = availableHeight;
const count = this.imagePaths.length;
const approxSide = Math.sqrt((contentW * contentH) / count);
let cols = Math.floor(contentW / approxSide);
if (cols < 1) cols = 1;
const rows = Math.ceil(count / cols);
const margin = 12;
const cellW = (contentW - (margin * (cols - 1))) / cols;
const cellH = (contentH - (margin * (rows - 1))) / rows;
this.imageRects = [];
try {
this.imagePaths.forEach((path, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = contentX + (col * (cellW + margin));
const y = contentY + (row * (cellH + margin));
this.imageRects.push({ i, x, y, w: cellW, h: cellH });
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, cellW, cellH, 6);
ctx.clip();
ctx.fillStyle = "#151515";
ctx.fillRect(x, y, cellW, cellH);
const img = this.images[i];
if (img && img.complete && img.width > 0) {
const imgRatio = img.width / img.height;
const cellRatio = cellW / cellH;
let dx, dy, dw, dh;
if (imgRatio > cellRatio) {
dw = cellW;
dh = cellW / imgRatio;
dx = x;
dy = y + (cellH - dh) / 2;
} else {
dh = cellH;
dw = cellH * imgRatio;
dy = y;
dx = x + (cellW - dw) / 2;
}
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, dw, dh);
} else if (img && img.broken) {
ctx.fillStyle = "#331111";
ctx.fillRect(x, y, cellW, cellH);
ctx.fillStyle = "#cc5555";
ctx.font = "10px Arial";
ctx.textAlign = "center";
ctx.fillText("Error", x + cellW / 2, y + cellH / 2);
}
ctx.restore();
if (this.selectedIndices.has(i)) {
ctx.lineWidth = 2;
ctx.strokeStyle = "#e0e0e0";
ctx.strokeRect(x, y, cellW, cellH);
const badgeSize = 20;
const bx = x + cellW - badgeSize - 6;
const by = y + 6;
ctx.beginPath();
ctx.roundRect(bx, by, badgeSize, badgeSize, 4);
ctx.fillStyle = "#e0e0e0";
ctx.fill();
ctx.fillStyle = "#111";
ctx.font = "bold 12px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("✓", bx + badgeSize / 2, by + badgeSize / 2 + 1);
}
});
} catch (error) {
console.error("Batch Selector: Error rendering images", error);
ctx.fillStyle = "red";
ctx.font = "12px Arial";
ctx.fillText("Render Error", 10, 10);
}
const totalBtnW = this.size[0] - 30;
const btnW = (totalBtnW - btnGap) / 2;
this.btnRects = [];
this.buttons.forEach((btn, i) => {
const col = btn.col;
const row = btn.row;
const x = 15 + (col * (btnW + btnGap));
const y = btnY + (row * (btnH + btnGap));
this.btnRects.push({
key: btn.key,
x: x,
y: y,
w: btnW,
h: btnH
});
ctx.fillStyle = btn.hover ? "#444" : "#2a2a2a";
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, btnW, btnH, 6);
} else {
ctx.rect(x, y, btnW, btnH);
}
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = btn.color;
ctx.stroke();
ctx.fillStyle = btn.color;
ctx.font = "bold 11px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(btn.label, x + btnW / 2, y + btnH / 2);
if (btn.hover && app.canvas.canvas) {
app.canvas.canvas.style.cursor = "pointer";
}
});
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (event, pos, graphPos) {
if (onMouseDown) onMouseDown.apply(this, arguments);
const x = pos[0];
const y = pos[1];
if (this.imageRects) {
for (const r of this.imageRects) {
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
if (this.selectedIndices.has(r.i)) this.selectedIndices.delete(r.i);
else this.selectedIndices.add(r.i);
this.setDirtyCanvas(true);
return true;
}
}
}
if (this.btnRects) {
for (const b of this.btnRects) {
if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
this.handleButtonClick(b.key);
return true;
}
}
}
};
const onMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function (event, pos, graphPos) {
if (onMouseMove) onMouseMove.apply(this, arguments);
const x = pos[0];
const y = pos[1];
let needsRedraw = false;
if (this.btnRects) {
for (let i = 0; i < this.btnRects.length; i++) {
const b = this.btnRects[i];
const isOver = x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
if (this.buttons[i].hover !== isOver) {
this.buttons[i].hover = isOver;
needsRedraw = true;
}
}
}
if (needsRedraw) this.setDirtyCanvas(true);
};
nodeType.prototype.handleButtonClick = async function (key) {
if (key === 'select_all') {
this.imagePaths.forEach((_, i) => this.selectedIndices.add(i));
} else if (key === 'deselect_all') {
this.selectedIndices.clear();
} else if (key === 'accept') {
if (this.selectedIndices.size === 0) {
this.showWarningPopup();
return;
}
const sorted = Array.from(this.selectedIndices).sort((a, b) => a - b);
this.sendSelection(sorted);
} else if (key === 'cancel') {
try { await api.interrupt(); } catch (e) { }
this.sendSelection([]);
}
this.setDirtyCanvas(true);
};
nodeType.prototype.sendSelection = async function (indices) {
try {
const response = await api.fetchApi("/rayko/image_selector", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: String(this.id), selection: indices })
});
if (response.ok) {
console.log(`[ImSELECT 🦊] Selection sent for node ${this.id}`);
this.stopHeartbeat();
this.imagePaths = [];
this.setDirtyCanvas(true);
}
} catch (e) {
console.error(`[ImSELECT 🦊] Send selection error: `, e);
alert(e);
}
};
api.addEventListener("rs-image-selector-start", (event) => {
const { id, images } = event.detail;
const node = app.graph.getNodeById(id);
if (!node) return;
if (node.imagePaths && node.imagePaths.length === images.length) {
const isSame = node.imagePaths.every((path, index) => path === images[index]);
if (isSame) {
node.setDirtyCanvas(true);
return;
}
}
node.imagePaths = images;
node.images = new Array(images.length);
node.selectedIndices.clear();
node.userResized = false;
images.forEach((f, i) => {
const img = new Image();
img.src = api.apiURL(`/view?filename=${f}&type=temp`);
img.onload = () => node.setDirtyCanvas(true);
img.onerror = () => {
img.broken = true;
node.setDirtyCanvas(true);
}
node.images[i] = img;
});
node.startHeartbeat();
console.log(`[ImSELECT 🦊] Heartbeat started for node ${id}`);
if (node.autoSize) {
node.autoSize(images.length);
}
node.setDirtyCanvas(true);
});
}
});