Files
fuselayer-comfyui-lite-tracker/web/rect_editor.js
T

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);
}
});
});
}
},
});