Added visibility change handling and error handling for image rendering in the batch selector.
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
// 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);
|
|
});
|
|
}
|
|
});
|