431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
const { app } = window.comfyAPI.app;
|
|
|
|
const MODAL_ID = "lt-grid-editor-modal";
|
|
|
|
function ensureModal() {
|
|
let modal = document.getElementById(MODAL_ID);
|
|
if (modal) return modal;
|
|
|
|
modal = document.createElement("div");
|
|
modal.id = MODAL_ID;
|
|
Object.assign(modal.style, {
|
|
position: "fixed",
|
|
inset: "0",
|
|
background: "rgba(0,0,0,0.6)",
|
|
display: "none",
|
|
zIndex: 10000,
|
|
});
|
|
|
|
const panel = document.createElement("div");
|
|
Object.assign(panel.style, {
|
|
position: "absolute",
|
|
left: "50%",
|
|
top: "50%",
|
|
transform: "translate(-50%,-50%)",
|
|
background: "#1e1e1e",
|
|
border: "1px solid #555",
|
|
padding: "10px",
|
|
boxShadow: "0 0 12px #000",
|
|
maxWidth: "95vw",
|
|
maxHeight: "95vh",
|
|
});
|
|
|
|
const header = document.createElement("div");
|
|
header.textContent = "LiteTracker: Grid Editor (Multi-Rectangle)";
|
|
Object.assign(header.style, { marginBottom: "6px", fontWeight: "bold" });
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 640;
|
|
canvas.height = 360;
|
|
Object.assign(canvas.style, {
|
|
background: "#111",
|
|
border: "1px solid #666",
|
|
cursor: "crosshair",
|
|
maxWidth: "90vw",
|
|
maxHeight: "75vh",
|
|
});
|
|
|
|
const controls = document.createElement("div");
|
|
Object.assign(controls.style, {
|
|
marginTop: "8px",
|
|
display: "flex",
|
|
gap: "8px",
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
});
|
|
|
|
const btnUndo = document.createElement("button");
|
|
btnUndo.textContent = "Undo";
|
|
const btnClear = document.createElement("button");
|
|
btnClear.textContent = "Clear All";
|
|
const btnApply = document.createElement("button");
|
|
btnApply.textContent = "Apply";
|
|
const btnCancel = document.createElement("button");
|
|
btnCancel.textContent = "Cancel";
|
|
|
|
controls.append(btnUndo, btnClear, btnApply, btnCancel);
|
|
|
|
panel.append(header, canvas, controls);
|
|
modal.appendChild(panel);
|
|
document.body.appendChild(modal);
|
|
|
|
// --- State ---
|
|
let rectangles = []; // [{x, y, w, h, rotation}, ...]
|
|
let currentRect = null; // Rectangle being drawn
|
|
let interactionMode = 'idle'; // 'idle' | 'drawing' | 'dragging' | 'rotating'
|
|
let draggingIndex = -1;
|
|
let rotatingIndex = -1;
|
|
let dragOffset = { x: 0, y: 0 }; // Offset between mouse and rect origin when dragging starts
|
|
let drawStartPos = { x: 0, y: 0 }; // Starting position when drawing new rect
|
|
let lastNode = null;
|
|
let bgImage = null;
|
|
|
|
// --- Helper functions ---
|
|
function getMousePos(e) {
|
|
const r = canvas.getBoundingClientRect();
|
|
// Scale mouse coordinates to match canvas internal dimensions
|
|
const scaleX = canvas.width / r.width;
|
|
const scaleY = canvas.height / r.height;
|
|
return {
|
|
x: (e.clientX - r.left) * scaleX,
|
|
y: (e.clientY - r.top) * scaleY
|
|
};
|
|
}
|
|
|
|
function getRotationHandlePos(rect) {
|
|
const cx = rect.x + rect.w / 2;
|
|
const cy = rect.y + rect.h / 2;
|
|
const handleDist = Math.max(rect.h / 2, rect.w / 2) + 25;
|
|
const hx = cx + handleDist * Math.sin(rect.rotation);
|
|
const hy = cy - handleDist * Math.cos(rect.rotation);
|
|
return { x: hx, y: hy };
|
|
}
|
|
|
|
function distance(x1, y1, x2, y2) {
|
|
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
}
|
|
|
|
function pointInRotatedRect(px, py, rect) {
|
|
const cx = rect.x + rect.w / 2;
|
|
const cy = rect.y + rect.h / 2;
|
|
const dx = px - cx;
|
|
const dy = py - cy;
|
|
const cos_a = Math.cos(-rect.rotation);
|
|
const sin_a = Math.sin(-rect.rotation);
|
|
const local_x = dx * cos_a - dy * sin_a;
|
|
const local_y = dx * sin_a + dy * cos_a;
|
|
return Math.abs(local_x) <= rect.w / 2 && Math.abs(local_y) <= rect.h / 2;
|
|
}
|
|
|
|
function drawRotatedRect(ctx, rect, isActive = false) {
|
|
const cx = rect.x + rect.w / 2;
|
|
const cy = rect.y + rect.h / 2;
|
|
|
|
ctx.save();
|
|
ctx.translate(cx, cy);
|
|
ctx.rotate(rect.rotation);
|
|
|
|
ctx.strokeStyle = isActive ? "#0ff" : "#0aa";
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(-rect.w / 2, -rect.h / 2, rect.w, rect.h);
|
|
|
|
// Corner handles
|
|
ctx.fillStyle = "#0ff";
|
|
const r = 3;
|
|
ctx.fillRect(-rect.w / 2 - r, -rect.h / 2 - r, r * 2, r * 2);
|
|
ctx.fillRect(rect.w / 2 - r, -rect.h / 2 - r, r * 2, r * 2);
|
|
ctx.fillRect(-rect.w / 2 - r, rect.h / 2 - r, r * 2, r * 2);
|
|
ctx.fillRect(rect.w / 2 - r, rect.h / 2 - r, r * 2, r * 2);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawRotationHandle(ctx, rect) {
|
|
const handle = getRotationHandlePos(rect);
|
|
ctx.fillStyle = "#ff0";
|
|
ctx.beginPath();
|
|
ctx.arc(handle.x, handle.y, 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = "#000";
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
|
|
function draw() {
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Background image
|
|
if (bgImage && bgImage.complete) {
|
|
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
|
|
} else {
|
|
ctx.fillStyle = "#111";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
// Draw all finalized rectangles
|
|
rectangles.forEach((rect, i) => {
|
|
const isActive = (i === rotatingIndex || i === draggingIndex);
|
|
drawRotatedRect(ctx, rect, isActive);
|
|
drawRotationHandle(ctx, rect);
|
|
});
|
|
|
|
// Draw current rectangle being drawn
|
|
if (currentRect) {
|
|
drawRotatedRect(ctx, currentRect, true);
|
|
}
|
|
}
|
|
|
|
// --- Mouse events ---
|
|
canvas.addEventListener("mousedown", (e) => {
|
|
if (e.button !== 0) return; // Only left click
|
|
const { x, y } = getMousePos(e);
|
|
|
|
// Priority 1: Check rotation handles (smaller target, needs priority)
|
|
for (let i = 0; i < rectangles.length; i++) {
|
|
const handle = getRotationHandlePos(rectangles[i]);
|
|
if (distance(x, y, handle.x, handle.y) < 15) { // Increased hit radius
|
|
interactionMode = 'rotating';
|
|
rotatingIndex = i;
|
|
draw();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Priority 2: Check if clicking inside existing rectangle (for dragging)
|
|
for (let i = rectangles.length - 1; i >= 0; i--) { // Reverse order for top-to-bottom
|
|
if (pointInRotatedRect(x, y, rectangles[i])) {
|
|
interactionMode = 'dragging';
|
|
draggingIndex = i;
|
|
dragOffset.x = x - rectangles[i].x;
|
|
dragOffset.y = y - rectangles[i].y;
|
|
draw();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Priority 3: Start drawing new rectangle (empty space clicked)
|
|
interactionMode = 'drawing';
|
|
drawStartPos.x = x;
|
|
drawStartPos.y = y;
|
|
currentRect = { x, y, w: 1, h: 1, rotation: 0 };
|
|
draw();
|
|
});
|
|
|
|
canvas.addEventListener("mousemove", (e) => {
|
|
const { x, y } = getMousePos(e);
|
|
|
|
if (interactionMode === 'drawing' && currentRect) {
|
|
// Update width/height based on mouse position
|
|
const dx = x - drawStartPos.x;
|
|
const dy = y - drawStartPos.y;
|
|
|
|
// Handle negative dimensions
|
|
if (dx >= 0) {
|
|
currentRect.x = drawStartPos.x;
|
|
currentRect.w = dx;
|
|
} else {
|
|
currentRect.x = x;
|
|
currentRect.w = -dx;
|
|
}
|
|
|
|
if (dy >= 0) {
|
|
currentRect.y = drawStartPos.y;
|
|
currentRect.h = dy;
|
|
} else {
|
|
currentRect.y = y;
|
|
currentRect.h = -dy;
|
|
}
|
|
|
|
draw();
|
|
} else if (interactionMode === 'dragging' && draggingIndex >= 0) {
|
|
const rect = rectangles[draggingIndex];
|
|
rect.x = x - dragOffset.x;
|
|
rect.y = y - dragOffset.y;
|
|
draw();
|
|
} else if (interactionMode === 'rotating' && rotatingIndex >= 0) {
|
|
const rect = rectangles[rotatingIndex];
|
|
const cx = rect.x + rect.w / 2;
|
|
const cy = rect.y + rect.h / 2;
|
|
rect.rotation = Math.atan2(x - cx, cy - y);
|
|
draw();
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener("mouseup", (e) => {
|
|
if (e.button !== 0) return;
|
|
|
|
if (interactionMode === 'drawing' && currentRect) {
|
|
// Only add if reasonably sized
|
|
if (currentRect.w > 5 && currentRect.h > 5) {
|
|
rectangles.push(currentRect);
|
|
}
|
|
currentRect = null;
|
|
}
|
|
|
|
interactionMode = 'idle';
|
|
draggingIndex = -1;
|
|
rotatingIndex = -1;
|
|
draw();
|
|
});
|
|
|
|
canvas.addEventListener("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
const { x, y } = getMousePos(e);
|
|
|
|
// Hit-test rectangles in reverse order (top to bottom)
|
|
for (let i = rectangles.length - 1; i >= 0; i--) {
|
|
if (pointInRotatedRect(x, y, rectangles[i])) {
|
|
rectangles.splice(i, 1);
|
|
draw();
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Buttons ---
|
|
btnUndo.onclick = () => {
|
|
if (rectangles.length > 0) {
|
|
rectangles.pop();
|
|
draw();
|
|
}
|
|
};
|
|
|
|
btnClear.onclick = () => {
|
|
rectangles = [];
|
|
draw();
|
|
};
|
|
|
|
btnApply.onclick = () => {
|
|
if (!lastNode) {
|
|
modal.style.display = "none";
|
|
return;
|
|
}
|
|
const wRect = lastNode.widgets?.find((w) => w.name === "rect_json");
|
|
if (wRect) {
|
|
wRect.value = JSON.stringify(rectangles);
|
|
app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
modal.style.display = "none";
|
|
};
|
|
|
|
btnCancel.onclick = () => {
|
|
modal.style.display = "none";
|
|
};
|
|
|
|
// --- Modal show function ---
|
|
modal._ltGridEditor = {
|
|
show(node) {
|
|
lastNode = node;
|
|
bgImage = null;
|
|
|
|
// Load background from node.properties
|
|
if (node.properties && node.properties.imgData && node.properties.imgData.base64) {
|
|
const base64Data = node.properties.imgData.base64;
|
|
const im = new Image();
|
|
im.onload = () => {
|
|
bgImage = im;
|
|
canvas.width = im.naturalWidth;
|
|
canvas.height = im.naturalHeight;
|
|
draw();
|
|
};
|
|
im.onerror = () => {
|
|
console.error("[LiteTracker] Failed to load background image");
|
|
bgImage = null;
|
|
draw();
|
|
};
|
|
console.log("[LiteTracker] Loading background, base64 length:", base64Data.length);
|
|
im.src = `data:image/png;base64,${base64Data}`;
|
|
} else {
|
|
canvas.width = 640;
|
|
canvas.height = 360;
|
|
draw();
|
|
}
|
|
|
|
// Load existing rectangles
|
|
const wRect = node.widgets?.find((w) => w.name === "rect_json");
|
|
if (wRect && wRect.value) {
|
|
try {
|
|
const parsed = JSON.parse(wRect.value);
|
|
if (Array.isArray(parsed)) {
|
|
rectangles = parsed;
|
|
} else if (parsed && typeof parsed === 'object') {
|
|
// Old single-rectangle format
|
|
rectangles = [parsed];
|
|
} else {
|
|
rectangles = [];
|
|
}
|
|
} catch {
|
|
rectangles = [];
|
|
}
|
|
} else {
|
|
rectangles = [];
|
|
}
|
|
|
|
modal.style.display = "block";
|
|
draw();
|
|
},
|
|
};
|
|
|
|
return modal;
|
|
}
|
|
|
|
// =====================================================================
|
|
// Extension registration
|
|
// =====================================================================
|
|
|
|
function chainCallback(object, property, callback) {
|
|
if (object == undefined) {
|
|
console.error("Tried to add callback to non-existent object");
|
|
return;
|
|
}
|
|
if (property in object) {
|
|
const callback_orig = object[property];
|
|
object[property] = function () {
|
|
const r = callback_orig.apply(this, arguments);
|
|
callback.apply(this, arguments);
|
|
return r;
|
|
};
|
|
} else {
|
|
object[property] = callback;
|
|
}
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "comfyui-lite-tracker.grid-editor",
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData?.name === "RectEditor") {
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
// Add open button
|
|
this.addWidget("button", "Open Grid Editor", "open", () => {
|
|
try {
|
|
const modal = ensureModal();
|
|
modal._ltGridEditor.show(this);
|
|
} catch (e) {
|
|
console.error("[LiteTracker] Grid Editor: failed to open modal:", e);
|
|
}
|
|
});
|
|
|
|
// Store base64 in properties on execution
|
|
chainCallback(this, "onExecuted", function (message) {
|
|
try {
|
|
const bg_base64 = message["bg_base64"];
|
|
if (bg_base64) {
|
|
if (!this.properties) {
|
|
this.properties = {};
|
|
}
|
|
this.properties.imgData = {
|
|
name: "bg_base64",
|
|
type: "image/png",
|
|
base64: Array.isArray(bg_base64) ? bg_base64[0] : bg_base64
|
|
};
|
|
console.log("[LiteTracker] Stored base64, length:", this.properties.imgData.base64.length);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[LiteTracker] onExecuted error:", e);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
},
|
|
}); |