Files
2026-07-10 19:49:35 +02:00

693 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DEFAULTS = {
scale_multiplier: 1.0,
x: 0,
y: 0,
width: 512,
height: 512,
aspect_ratio: "Free",
};
// Pure ratios only: fixed pixel resolutions, paper sizes and social-media formats were removed.
const ASPECT_CHOICES = [
"Free",
"1:1 (Perfect Square)",
"2:1 (Double Wide)", "1:2 (Split Vertical)",
"2:3 (Classic Portrait)", "3:2 (Classic Landscape)",
"3:4 (Portrait Classic)", "4:3 (Classic Landscape)",
"4:5 (Artistic Frame)", "5:4 (Balanced Frame)",
"5:7 (Balanced Portrait)", "7:5 (Elegant Landscape)",
"5:8 (Tall Portrait)", "8:5 (Cinematic View)",
"7:9 (Modern Portrait)", "9:7 (Artful Horizon)",
"9:16 (Slim Vertical)", "16:9 (Panorama)",
"9:19 (Tall Slim)", "19:9 (Cinematic Ultrawide)",
"9:21 (Ultra Tall)", "21:9 (Epic Ultrawide)",
"9:32 (Skyline)", "32:9 (Extreme Ultrawide)",
"3:5 (Elegant Vertical)", "5:3 (Wide Horizon)",
"2:5 (Tall Banner)", "5:2 (Wide Banner)",
"1:3 (Vertical Triptych)", "3:1 (Horizontal Triptych)",
"1:4 (Ultra Vertical Strip)", "4:1 (Ultra Wide Strip)",
"10:16 (Poster Portrait)", "16:10 (Wide Monitor)",
"10:18 (Tall Poster)", "18:10 (Wide Poster)",
"11:14 (Portrait Print)", "14:11 (Landscape Print)",
"11:17 (Tabloid Portrait)", "17:11 (Tabloid Landscape)",
"8:11 (Letter Portrait)", "11:8 (Letter Landscape)",
"8:14 (Legal Portrait)", "14:8 (Legal Landscape)",
"1:1.85 (Cinema Vertical)", "1.85:1 (Cinema Flat)",
"1:2.35 (Cinemascope Vertical)", "2.35:1 (Cinemascope)",
"2.39:1 (Cinema Scope)", "2.40:1 (Cinema Wide)",
"2.20:1 (70mm)", "65:24 (XPan Panorama)",
"6:7 (Medium Format Portrait)", "7:6 (Medium Format Landscape)",
"6:9 (Film Portrait)", "9:6 (Film Landscape)",
];
const nodeId = Number(new URLSearchParams(location.search).get("nodeId"));
const bridge = window.opener?.__orion4dInteractiveCropBridge;
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const stage = document.querySelector("#previewStage");
const statusEl = document.querySelector("#status");
const cropInfo = document.querySelector("#cropInfo");
const emptyMessage = document.querySelector("#emptyMessage");
const alignmentSelect = document.querySelector("#alignment");
const fields = {
scale_multiplier: document.querySelector("#scale_multiplier"),
x: document.querySelector("#x"),
y: document.querySelector("#y"),
width: document.querySelector("#width"),
height: document.querySelector("#height"),
x_range: document.querySelector("#x_range"),
y_range: document.querySelector("#y_range"),
width_range: document.querySelector("#width_range"),
height_range: document.querySelector("#height_range"),
aspect_ratio: document.querySelector("#aspect_ratio"),
};
const scaleOutput = document.querySelector("#scaleOutput");
const sourceSize = document.querySelector("#sourceSize");
const scaledCanvas = document.querySelector("#scaledCanvas");
const scaledCrop = document.querySelector("#scaledCrop");
const scaledPosition = document.querySelector("#scaledPosition");
for (const choice of ASPECT_CHOICES) fields.aspect_ratio.add(new Option(choice, choice));
let sourceImage = null;
let sourceUrl = null;
let imagePollTimer = null;
let drawRaf = 0;
let viewportWidth = 0;
let viewportHeight = 0;
let imageRect = { x: 0, y: 0, w: 0, h: 0 };
let selection = { x: 0, y: 0, w: 512, h: 512 };
let pointerState = null;
const HANDLE_SIZE = 12;
const HANDLE_HIT = 24;
function number(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function parseRatio(value = fields.aspect_ratio.value) {
const text = String(value || "").trim();
if (!text || text.toLowerCase().startsWith("free")) return 0;
const match = text.match(/(\d+(?:\.\d+)?)\s*[:x×]\s*(\d+(?:\.\d+)?)/);
if (!match) return 0;
const w = Number(match[1]);
const h = Number(match[2]);
return w > 0 && h > 0 ? w / h : 0;
}
function imageWidth() { return sourceImage?.naturalWidth || 0; }
function imageHeight() { return sourceImage?.naturalHeight || 0; }
function normalizeSelection(input = selection) {
const iw = imageWidth();
const ih = imageHeight();
const out = {
x: Math.round(number(input.x, 0)),
y: Math.round(number(input.y, 0)),
w: Math.round(Math.max(1, number(input.w, 1))),
h: Math.round(Math.max(1, number(input.h, 1))),
};
if (!iw || !ih) return out;
out.x = clamp(out.x, 0, Math.max(0, iw - 1));
out.y = clamp(out.y, 0, Math.max(0, ih - 1));
out.w = clamp(out.w, 1, Math.max(1, iw - out.x));
out.h = clamp(out.h, 1, Math.max(1, ih - out.y));
return out;
}
function readForm() {
selection = normalizeSelection({
x: number(fields.x.value, 0),
y: number(fields.y.value, 0),
w: number(fields.width.value, 512),
h: number(fields.height.value, 512),
});
return {
scale_multiplier: clamp(number(fields.scale_multiplier.value, 1), 0.1, 10),
x: selection.x,
y: selection.y,
width: selection.w,
height: selection.h,
aspect_ratio: fields.aspect_ratio.value || "Free",
};
}
function writeForm(settings = DEFAULTS, normalize = true) {
const next = { ...DEFAULTS, ...settings };
fields.scale_multiplier.value = clamp(number(next.scale_multiplier, 1), 0.1, 10);
fields.aspect_ratio.value = ASPECT_CHOICES.includes(String(next.aspect_ratio)) ? String(next.aspect_ratio) : "Free";
alignmentSelect.value = "free";
selection = {
x: Math.round(number(next.x, 0)),
y: Math.round(number(next.y, 0)),
w: Math.round(Math.max(1, number(next.width, 512))),
h: Math.round(Math.max(1, number(next.height, 512))),
};
if (normalize) selection = normalizeSelection(selection);
syncFieldsFromSelection({ preserveAlignment: true });
scheduleDraw();
}
function setRangeLimits(range, numberInput, min, max) {
const safeMin = Math.round(min);
const safeMax = Math.max(safeMin, Math.round(max));
range.min = String(safeMin);
range.max = String(safeMax);
numberInput.min = String(safeMin);
numberInput.max = String(safeMax);
}
function updateDynamicControls() {
const iw = imageWidth() || 16384;
const ih = imageHeight() || 16384;
const maxW = imageWidth() ? Math.max(1, iw - selection.x) : 16384;
const maxH = imageHeight() ? Math.max(1, ih - selection.y) : 16384;
const maxX = imageWidth() ? Math.max(0, iw - selection.w) : 16384;
const maxY = imageHeight() ? Math.max(0, ih - selection.h) : 16384;
setRangeLimits(fields.width_range, fields.width, 1, maxW);
setRangeLimits(fields.height_range, fields.height, 1, maxH);
setRangeLimits(fields.x_range, fields.x, 0, maxX);
setRangeLimits(fields.y_range, fields.y, 0, maxY);
fields.x.value = selection.x;
fields.y.value = selection.y;
fields.width.value = selection.w;
fields.height.value = selection.h;
fields.x_range.value = clamp(selection.x, 0, maxX);
fields.y_range.value = clamp(selection.y, 0, maxY);
fields.width_range.value = clamp(selection.w, 1, maxW);
fields.height_range.value = clamp(selection.h, 1, maxH);
}
function syncFieldsFromSelection({ preserveAlignment = false } = {}) {
selection = normalizeSelection(selection);
if (!preserveAlignment) alignmentSelect.value = "free";
updateDynamicControls();
updateMetrics();
}
function updateMetrics() {
const scale = clamp(number(fields.scale_multiplier.value, 1), 0.1, 10);
const iw = imageWidth();
const ih = imageHeight();
scaleOutput.textContent = `${scale.toFixed(1)}×`;
sourceSize.textContent = iw && ih ? `${iw} × ${ih}` : "—";
scaledCanvas.textContent = iw && ih ? `${Math.round(iw * scale)} × ${Math.round(ih * scale)}` : "—";
scaledCrop.textContent = `${Math.round(selection.w * scale)} × ${Math.round(selection.h * scale)}`;
scaledPosition.textContent = `${Math.round(selection.x * scale)}, ${Math.round(selection.y * scale)}`;
cropInfo.textContent = `${selection.w} × ${selection.h} px · X ${selection.x} · Y ${selection.y}`;
}
function fitCurrentSelectionToRatio() {
const ratio = parseRatio();
const iw = imageWidth();
const ih = imageHeight();
if (!ratio || !iw || !ih) return;
const cx = selection.x + selection.w / 2;
const cy = selection.y + selection.h / 2;
let w = selection.w;
let h = w / ratio;
if (h > selection.h) {
h = selection.h;
w = h * ratio;
}
w = Math.min(w, iw, ih * ratio);
h = w / ratio;
const x = clamp(cx - w / 2, 0, iw - w);
const y = clamp(cy - h / 2, 0, ih - h);
selection = normalizeSelection({ x, y, w, h });
syncFieldsFromSelection({ preserveAlignment: true });
}
function alignSelection(mode) {
const iw = imageWidth();
const ih = imageHeight();
if (!iw || !ih) return;
let x = selection.x;
let y = selection.y;
const centerX = Math.round((iw - selection.w) / 2);
const centerY = Math.round((ih - selection.h) / 2);
const rightX = Math.max(0, iw - selection.w);
const bottomY = Math.max(0, ih - selection.h);
switch (mode) {
case "top_left": x = 0; y = 0; break;
case "top": x = centerX; y = 0; break;
case "top_right": x = rightX; y = 0; break;
case "left": x = 0; y = centerY; break;
case "center": x = centerX; y = centerY; break;
case "right": x = rightX; y = centerY; break;
case "bottom_left": x = 0; y = bottomY; break;
case "bottom": x = centerX; y = bottomY; break;
case "bottom_right": x = rightX; y = bottomY; break;
default: alignmentSelect.value = "free"; return;
}
selection = normalizeSelection({ ...selection, x, y });
alignmentSelect.value = mode;
syncFieldsFromSelection({ preserveAlignment: true });
scheduleDraw();
}
function setFullImage() {
const iw = imageWidth();
const ih = imageHeight();
if (!iw || !ih) return;
const ratio = parseRatio();
let w = iw;
let h = ih;
if (ratio) {
if (w / h > ratio) w = h * ratio;
else h = w / ratio;
}
selection = normalizeSelection({
x: Math.round((iw - w) / 2),
y: Math.round((ih - h) / 2),
w: Math.round(w),
h: Math.round(h),
});
alignmentSelect.value = "center";
syncFieldsFromSelection({ preserveAlignment: true });
scheduleDraw();
}
function ensureCanvasSize() {
const width = Math.max(1, Math.floor(stage.clientWidth));
const height = Math.max(1, Math.floor(stage.clientHeight));
const dpr = Math.max(1, window.devicePixelRatio || 1);
if (width !== viewportWidth || height !== viewportHeight || canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
viewportWidth = width;
viewportHeight = height;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { width, height };
}
function calculateImageRect() {
const iw = imageWidth();
const ih = imageHeight();
const padding = 24;
if (!iw || !ih) {
imageRect = { x: 0, y: 0, w: 0, h: 0 };
return imageRect;
}
const availableW = Math.max(1, viewportWidth - padding * 2);
const availableH = Math.max(1, viewportHeight - padding * 2);
const scale = Math.min(availableW / iw, availableH / ih);
const w = iw * scale;
const h = ih * scale;
imageRect = { x: (viewportWidth - w) / 2, y: (viewportHeight - h) / 2, w, h };
return imageRect;
}
function imageToCanvas(x, y) {
return {
x: imageRect.x + (x / imageWidth()) * imageRect.w,
y: imageRect.y + (y / imageHeight()) * imageRect.h,
};
}
function canvasToImage(x, y) {
const iw = imageWidth();
const ih = imageHeight();
return {
x: clamp(((x - imageRect.x) / Math.max(1e-6, imageRect.w)) * iw, 0, iw),
y: clamp(((y - imageRect.y) / Math.max(1e-6, imageRect.h)) * ih, 0, ih),
inside: x >= imageRect.x && x <= imageRect.x + imageRect.w && y >= imageRect.y && y <= imageRect.y + imageRect.h,
};
}
function selectionCanvasRect() {
const p1 = imageToCanvas(selection.x, selection.y);
const p2 = imageToCanvas(selection.x + selection.w, selection.y + selection.h);
return { x: p1.x, y: p1.y, w: p2.x - p1.x, h: p2.y - p1.y };
}
function handlePositions() {
const r = selectionCanvasRect();
return {
nw: { x: r.x, y: r.y },
ne: { x: r.x + r.w, y: r.y },
se: { x: r.x + r.w, y: r.y + r.h },
sw: { x: r.x, y: r.y + r.h },
};
}
function hitHandle(point) {
const handles = handlePositions();
for (const [name, handle] of Object.entries(handles)) {
if (Math.abs(point.x - handle.x) <= HANDLE_HIT && Math.abs(point.y - handle.y) <= HANDLE_HIT) return name;
}
return null;
}
function pointInsideSelection(point) {
const r = selectionCanvasRect();
return point.x >= r.x && point.x <= r.x + r.w && point.y >= r.y && point.y <= r.y + r.h;
}
function draw() {
drawRaf = 0;
const { width, height } = ensureCanvasSize();
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#090d12";
ctx.fillRect(0, 0, width, height);
calculateImageRect();
if (!sourceImage) {
emptyMessage.hidden = false;
return;
}
emptyMessage.hidden = true;
ctx.drawImage(sourceImage, imageRect.x, imageRect.y, imageRect.w, imageRect.h);
const crop = selectionCanvasRect();
ctx.save();
ctx.fillStyle = "rgba(0, 0, 0, .62)";
ctx.beginPath();
ctx.rect(imageRect.x, imageRect.y, imageRect.w, imageRect.h);
ctx.rect(crop.x, crop.y, crop.w, crop.h);
ctx.fill("evenodd");
ctx.restore();
ctx.save();
ctx.strokeStyle = "rgba(105, 176, 255, .98)";
ctx.lineWidth = 2;
ctx.strokeRect(crop.x + 0.5, crop.y + 0.5, crop.w - 1, crop.h - 1);
ctx.strokeStyle = "rgba(255,255,255,.34)";
ctx.lineWidth = 1;
for (let i = 1; i <= 2; i++) {
const xx = crop.x + crop.w * i / 3;
const yy = crop.y + crop.h * i / 3;
ctx.beginPath(); ctx.moveTo(xx, crop.y); ctx.lineTo(xx, crop.y + crop.h); ctx.stroke();
ctx.beginPath(); ctx.moveTo(crop.x, yy); ctx.lineTo(crop.x + crop.w, yy); ctx.stroke();
}
for (const handle of Object.values(handlePositions())) {
ctx.fillStyle = "#eaf4ff";
ctx.fillRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
ctx.strokeStyle = "#267edc";
ctx.strokeRect(handle.x - HANDLE_SIZE / 2 + 0.5, handle.y - HANDLE_SIZE / 2 + 0.5, HANDLE_SIZE - 1, HANDLE_SIZE - 1);
}
const label = `${selection.w} × ${selection.h}`;
ctx.font = "12px system-ui, sans-serif";
const labelW = ctx.measureText(label).width + 14;
const labelY = crop.y > 28 ? crop.y - 25 : crop.y + 7;
ctx.fillStyle = "rgba(12, 18, 26, .9)";
ctx.fillRect(crop.x, labelY, labelW, 20);
ctx.fillStyle = "#dcecff";
ctx.fillText(label, crop.x + 7, labelY + 14);
ctx.restore();
}
function scheduleDraw() {
updateMetrics();
if (!drawRaf) drawRaf = requestAnimationFrame(draw);
}
function localPointer(event) {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function anchorForHandle(handle, start) {
switch (handle) {
case "nw": return { x: start.x + start.w, y: start.y + start.h, sx: -1, sy: -1 };
case "ne": return { x: start.x, y: start.y + start.h, sx: 1, sy: -1 };
case "se": return { x: start.x, y: start.y, sx: 1, sy: 1 };
case "sw": return { x: start.x + start.w, y: start.y, sx: -1, sy: 1 };
default: return { x: start.x, y: start.y, sx: 1, sy: 1 };
}
}
function selectionFromAnchor(anchor, pointer, sx, sy, ratio) {
const iw = imageWidth();
const ih = imageHeight();
const maxW = sx > 0 ? iw - anchor.x : anchor.x;
const maxH = sy > 0 ? ih - anchor.y : anchor.y;
let w = clamp(Math.abs(pointer.x - anchor.x), 1, Math.max(1, maxW));
let h = clamp(Math.abs(pointer.y - anchor.y), 1, Math.max(1, maxH));
if (ratio) {
if (w / h > ratio) h = w / ratio;
else w = h * ratio;
w = Math.min(w, maxW, maxH * ratio);
h = w / ratio;
w = Math.max(1, w);
h = Math.max(1, h);
}
const movingX = anchor.x + sx * w;
const movingY = anchor.y + sy * h;
return normalizeSelection({
x: Math.min(anchor.x, movingX),
y: Math.min(anchor.y, movingY),
w: Math.abs(movingX - anchor.x),
h: Math.abs(movingY - anchor.y),
});
}
function updateCursor(point) {
const handle = sourceImage ? hitHandle(point) : null;
if (handle === "nw" || handle === "se") canvas.style.cursor = "nwse-resize";
else if (handle === "ne" || handle === "sw") canvas.style.cursor = "nesw-resize";
else if (pointInsideSelection(point)) canvas.style.cursor = "move";
else canvas.style.cursor = "crosshair";
}
canvas.addEventListener("pointerdown", event => {
if (event.button !== 0 || !sourceImage) return;
const local = localPointer(event);
const imagePoint = canvasToImage(local.x, local.y);
if (!imagePoint.inside) return;
const handle = hitHandle(local);
const start = { ...selection };
if (handle) {
pointerState = {
mode: "resize",
pointerId: event.pointerId,
handle,
start,
anchor: anchorForHandle(handle, start),
};
} else if (pointInsideSelection(local)) {
pointerState = {
mode: "move",
pointerId: event.pointerId,
start,
startPoint: { x: imagePoint.x, y: imagePoint.y },
};
} else {
pointerState = {
mode: "new",
pointerId: event.pointerId,
startPoint: { x: imagePoint.x, y: imagePoint.y },
};
selection = normalizeSelection({ x: imagePoint.x, y: imagePoint.y, w: 1, h: 1 });
}
alignmentSelect.value = "free";
canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
syncFieldsFromSelection();
scheduleDraw();
});
canvas.addEventListener("pointermove", event => {
const local = localPointer(event);
if (!pointerState) {
updateCursor(local);
return;
}
const point = canvasToImage(local.x, local.y);
const ratio = parseRatio();
if (pointerState.mode === "move") {
const dx = point.x - pointerState.startPoint.x;
const dy = point.y - pointerState.startPoint.y;
selection = normalizeSelection({
...pointerState.start,
x: clamp(pointerState.start.x + dx, 0, imageWidth() - pointerState.start.w),
y: clamp(pointerState.start.y + dy, 0, imageHeight() - pointerState.start.h),
});
} else if (pointerState.mode === "resize") {
const { x, y, sx, sy } = pointerState.anchor;
selection = selectionFromAnchor({ x, y }, point, sx, sy, ratio);
} else if (pointerState.mode === "new") {
const start = pointerState.startPoint;
const sx = point.x >= start.x ? 1 : -1;
const sy = point.y >= start.y ? 1 : -1;
selection = selectionFromAnchor(start, point, sx, sy, ratio);
}
syncFieldsFromSelection();
scheduleDraw();
event.preventDefault();
});
function endPointer(event) {
if (!pointerState) return;
if (event.pointerId === pointerState.pointerId) canvas.releasePointerCapture?.(event.pointerId);
pointerState = null;
syncFieldsFromSelection();
scheduleDraw();
}
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("contextmenu", event => event.preventDefault());
function normalizeSourceUrl(url) {
const raw = String(url || "");
if (!raw) return "";
try {
const parsed = new URL(raw, window.location.href);
for (const key of ["orion_t", "t", "timestamp", "cache", "cb", "rand"]) parsed.searchParams.delete(key);
return `${parsed.pathname}?${parsed.searchParams.toString()}`.replace(/\?$/, "");
} catch {
return raw.replace(/([?&])(orion_t|t|timestamp|cache|cb|rand)=[^&]*/gi, "$1").replace(/[?&]+$/, "").replace(/\?&/, "?");
}
}
function loadImage(url, force = false) {
const nextUrl = url || null;
if (!force && normalizeSourceUrl(nextUrl) === normalizeSourceUrl(sourceUrl) && sourceImage) return;
sourceUrl = nextUrl;
if (!nextUrl) {
sourceImage = null;
statusEl.textContent = "Connected — waiting for an input preview";
updateMetrics();
scheduleDraw();
return;
}
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
sourceImage = image;
selection = normalizeSelection(selection);
syncFieldsFromSelection({ preserveAlignment: true });
statusEl.textContent = `Connected — ${image.naturalWidth} × ${image.naturalHeight}`;
scheduleDraw();
};
image.onerror = () => {
sourceImage = null;
statusEl.textContent = "Connected — preview unavailable";
scheduleDraw();
};
image.src = `${nextUrl}${String(nextUrl).includes("?") ? "&" : "?"}orion_t=${Date.now()}`;
}
function startImagePolling() {
if (imagePollTimer || !bridge) return;
imagePollTimer = window.setInterval(() => {
const next = bridge.refreshImage?.(nodeId);
if (normalizeSourceUrl(next) !== normalizeSourceUrl(sourceUrl)) loadImage(next, true);
else if (!sourceImage && next) loadImage(next, false);
}, 1000);
}
function apply() {
const next = readForm();
const applied = bridge?.applySettings?.(nodeId, next);
if (applied) {
const alignment = alignmentSelect.value;
writeForm(applied);
alignmentSelect.value = alignment;
statusEl.textContent = "Crop settings applied to ComfyUI";
} else {
statusEl.textContent = "Unable to reach the ComfyUI node";
}
}
function editSelectionField(name, rawValue) {
const value = Math.round(number(rawValue, name === "width" || name === "height" ? 1 : 0));
const ratio = parseRatio();
const next = { ...selection };
if (name === "x") next.x = value;
else if (name === "y") next.y = value;
else if (name === "width") {
next.w = Math.max(1, value);
if (ratio) next.h = Math.max(1, Math.round(next.w / ratio));
} else if (name === "height") {
next.h = Math.max(1, value);
if (ratio) next.w = Math.max(1, Math.round(next.h * ratio));
}
selection = normalizeSelection(next);
alignmentSelect.value = "free";
syncFieldsFromSelection();
scheduleDraw();
}
for (const name of ["x", "y", "width", "height"]) {
fields[name].addEventListener("input", () => editSelectionField(name, fields[name].value));
fields[`${name}_range`].addEventListener("input", () => editSelectionField(name, fields[`${name}_range`].value));
}
fields.scale_multiplier.addEventListener("input", updateMetrics);
fields.aspect_ratio.addEventListener("change", () => {
const alignment = alignmentSelect.value;
fitCurrentSelectionToRatio();
if (alignment !== "free") alignSelection(alignment);
else scheduleDraw();
});
alignmentSelect.addEventListener("change", () => alignSelection(alignmentSelect.value));
document.querySelector("#fullImage").addEventListener("click", setFullImage);
document.querySelector("#centerCrop").addEventListener("click", () => alignSelection("center"));
document.querySelector("#refreshImage").addEventListener("click", () => loadImage(bridge?.refreshImage?.(nodeId), true));
document.querySelector("#applySettings").addEventListener("click", apply);
document.querySelector("#applyClose").addEventListener("click", () => { apply(); window.close(); });
document.querySelector("#closeApp").addEventListener("click", () => window.close());
document.querySelector("#resetSettings").addEventListener("click", () => {
const reset = bridge?.resetSettings?.(nodeId) || DEFAULTS;
writeForm(reset);
statusEl.textContent = "Crop settings reset";
});
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(stage);
window.addEventListener("resize", scheduleDraw, { passive: true });
window.addEventListener("beforeunload", () => {
if (imagePollTimer) clearInterval(imagePollTimer);
resizeObserver.disconnect();
});
if (!window.opener || !bridge || !Number.isFinite(nodeId)) {
statusEl.textContent = "Open this application from the Interactive Crop node";
writeForm(DEFAULTS, false);
} else {
const state = bridge.getState(nodeId);
if (!state) {
statusEl.textContent = "Interactive Crop node not found";
writeForm(DEFAULTS, false);
} else {
document.title = `${state.title} — Orion4D`;
writeForm(state.settings, false);
loadImage(state.imageSrc, true);
startImagePolling();
}
}