// Developed by 3R3BOS import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; app.registerExtension({ name: "3R3BOS.BatchSelector", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name !== "Batch Selector (3R3BOS)") return; // --- NATIVE CANVAS IMPLEMENTATION V3 (CLASSY / SOBER) --- // Style Reference: Image Comparer Slider (Dark Grays, White Accents, No Neon) const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { if (onNodeCreated) onNodeCreated.apply(this, arguments); this.images = []; this.selectedIndices = new Set(); this.imagePaths = []; // Buttons State this.buttons = { all: { text: "ALL", hover: false }, none: { text: "NONE", hover: false }, cancel: { text: "CANCEL", hover: false }, confirm: { text: "CONFIRM", hover: false } }; // FIX: Force redraw when tab becomes visible to handle background throttling issues this.onVisibilityChange = () => { if (document.visibilityState === "visible") { this.setDirtyCanvas(true); } }; document.addEventListener("visibilitychange", this.onVisibilityChange); }; // Cleanup listener when node is removed (optional but good practice) const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { if (onRemoved) onRemoved.apply(this, arguments); if (this.onVisibilityChange) { document.removeEventListener("visibilitychange", this.onVisibilityChange); } }; const onDrawForeground = nodeType.prototype.onDrawForeground; nodeType.prototype.onDrawForeground = function (ctx) { if (onDrawForeground) onDrawForeground.apply(this, arguments); if (!this.imagePaths.length) { // Placeholder - Elegant Font ctx.fillStyle = "#888"; ctx.font = "italic 13px 'Segoe UI', Roboto, sans-serif"; ctx.textAlign = "center"; ctx.fillText("Waiting for batch...", this.size[0] / 2, this.size[1] / 2); return; } // Layout Constants const margin = 12; // More breathing room const topBarH = 10; const bottomBarH = 45; // Slightly taller for elegant buttons const contentX = margin; const contentY = topBarH; const contentW = this.size[0] - (margin * 2); const contentH = this.size[1] - topBarH - bottomBarH - margin; // Grid Calc 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 cellW = (contentW - (margin * (cols - 1))) / cols; const cellH = (contentH - (margin * (rows - 1))) / rows; this.imageRects = []; // Safely draw images 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 }); // Clip Container ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, cellW, cellH, 6); // Smoother radius ctx.clip(); // Background (Darker/Sober) ctx.fillStyle = "#151515"; ctx.fillRect(x, y, cellW, cellH); // Image Render 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) { // Image is wider than cell -> fit width dw = cellW; dh = cellW / imgRatio; dx = x; dy = y + (cellH - dh) / 2; } else { // Image is taller than cell -> fit height 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) { // Broken image placeholder 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(); // Selection Overlay (CLASSY STYLE) if (this.selectedIndices.has(i)) { // Border: White, Thin, Elegant ctx.lineWidth = 2; ctx.strokeStyle = "#e0e0e0"; ctx.strokeRect(x, y, cellW, cellH); // Checkmark Badge: Minimalist Square or Circle in corner 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"; // Classy Light Gray ctx.fill(); ctx.fillStyle = "#111"; // Dark Text 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); // Draw error message on canvas so user knows what happened ctx.fillStyle = "red"; ctx.font = "12px Arial"; ctx.fillText("Render Error", 10, 10); } // Buttons (Footer) - SOBER STYLE // Drawn outside the try-catch for images so they always appear const btnGap = 8; const totalBtnW = this.size[0] - (margin * 2); const unit = (totalBtnW - (btnGap * 3)) / 6; // Palette: Derived from Image Slider (#353535 bg, #eee text) // Pressed/Action buttons get a bit more highlight but keep it monochrome/muted. const btnSpecs = [ { key: 'all', w: unit, bg: "#252525", hoverBg: "#353535", text: "#888", hoverText: "#fff" }, { key: 'none', w: unit, bg: "#252525", hoverBg: "#353535", text: "#888", hoverText: "#fff" }, // Cancel: Dark Red tint or just Dark? Let's go Dark w/ Red Accent Text // Confirm: Dark w/ White Text or subtle Primary { key: 'cancel', w: unit * 2, bg: "#252525", hoverBg: "#3a2020", text: "#a55", hoverText: "#f88" }, { key: 'confirm', w: unit * 2, bg: "#e0e0e0", hoverBg: "#fff", text: "#111", hoverText: "#000" } // Inverted for Primary ]; let cursorX = margin; const btnY = this.size[1] - margin - 32; const btnH = 32; this.btnRects = []; btnSpecs.forEach(spec => { const btnState = this.buttons[spec.key]; const x = cursorX; this.btnRects.push({ key: spec.key, x, y: btnY, w: spec.w, h: btnH }); // Button Background ctx.fillStyle = btnState.hover ? spec.hoverBg : spec.bg; ctx.beginPath(); ctx.roundRect(x, btnY, spec.w, btnH, 4); // Slightly sharper corners (4px) ctx.fill(); // Border (Subtle) ctx.lineWidth = 1; ctx.strokeStyle = "#333"; ctx.stroke(); // Text ctx.fillStyle = btnState.hover ? spec.hoverText : spec.text; ctx.font = (spec.key === 'confirm' ? "bold " : "") + "12px 'Segoe UI', Roboto, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(btnState.text, x + spec.w / 2, btnY + btnH / 2); cursorX += spec.w + btnGap; }); }; // 2. Interaction 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; } } } }; // Hover const onMouseMove = nodeType.prototype.onMouseMove; nodeType.prototype.onMouseMove = function (event, pos, graphPos) { if (onMouseDown) onMouseDown.apply(this, arguments); const x = pos[0]; const y = pos[1]; let needsRedraw = false; if (this.btnRects) { for (const b of this.btnRects) { const isOver = x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h; if (this.buttons[b.key].hover !== isOver) { this.buttons[b.key].hover = isOver; needsRedraw = true; } } } if (needsRedraw) this.setDirtyCanvas(true); }; // 3. Logic nodeType.prototype.handleButtonClick = async function (key) { if (key === 'all') { this.imagePaths.forEach((_, i) => this.selectedIndices.add(i)); } else if (key === 'none') { this.selectedIndices.clear(); } else if (key === 'cancel') { try { await api.interrupt(); } catch (e) { } this.sendSelection([]); } else if (key === 'confirm') { const sorted = Array.from(this.selectedIndices).sort((a, b) => a - b); this.sendSelection(sorted); } this.setDirtyCanvas(true); }; nodeType.prototype.sendSelection = async function (indices) { try { await api.fetchApi("/3r3bos/batch_selection", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: String(this.id), selection: indices }) }); this.imagePaths = []; this.setDirtyCanvas(true); } catch (e) { alert(e); } }; api.addEventListener("3r3bos-batch-selector-start", (event) => { const { id, images } = event.detail; const node = app.graph.getNodeById(id); if (!node) return; // DEDUPLICATION: Check if we are already displaying this batch // If images are identical, just ensure canvas is dirty and return // This prevents resetting selection when the backend resends the event (heartbeat) 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(); 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; }); // Auto size const areaPerImage = 150 * 150; const totalArea = areaPerImage * images.length; const targetW = 340; const targetH = (totalArea / targetW) + 100; if (node.size[0] < targetW || node.size[1] < targetH) { node.setSize([Math.max(node.size[0], targetW), Math.max(node.size[1], targetH)]); } node.setDirtyCanvas(true); }); } });