- New 'flip' input (None/Horizontal/Vertical, default None).
- Selecting a flip mirrors the image in the browser (canvas), saves it
to input/ as {name}-flipH.png / {name}-flipV.png, and switches the
node's image input to the flipped file.
- The flipped image is used everywhere (preview, mask editor, output);
the crop rectangle is framed on the flipped image (WYSIWYG).
- 'None' reverts to the original image.
- No canvas rendering tricks: the flip is a real file in input/, so it
is consistent across all contexts.
Files changed:
- load_image_crop.py: FLIP_OPTIONS + flip input in INPUT_TYPES
- web/js/loadImageCrop.js: applyFlip() + flip widget binding
- pyproject.toml: version 1.0.7
- CHANGELOG.md: v1.0.7 entry
- README.md: Flip section updated
1335 lines
50 KiB
JavaScript
1335 lines
50 KiB
JavaScript
/*
|
||
* LoadImageCrop - visual crop for the official Load Image preview.
|
||
*
|
||
* Architecture:
|
||
* - No static ES imports: the frontend serves /scripts/app.js as a shim that
|
||
* reads window.comfyAPI at evaluation time, so it is imported dynamically
|
||
* (absolute path) with a retry loop.
|
||
* - The official image preview is the canvas-drawn custom widget
|
||
* "$$canvas-image-preview". We wrap its drawWidget (called with
|
||
* (ctx, {width, previewImages}) and reading widget.y / widget.computedHeight)
|
||
* to:
|
||
* 1. let the core draw the image (it defers the drawImage to a
|
||
* queueMicrotask, so a synchronous overlay would be covered);
|
||
* 2. queue our own microtask AFTER the core's, and draw the crop
|
||
* overlay (dim outside + red border + output size) using the same
|
||
* transform the core captured -> pixel-perfect alignment.
|
||
* - Interaction:
|
||
* * crop drag -> wraps widget.onPointerDown: the drag starts only when
|
||
* the pointer is INSIDE the crop box (clicks elsewhere keep the core
|
||
* canvas behavior); in Free mode a corner hit resizes the box instead
|
||
* of moving it. The pointer session's onDrag plus the node-level
|
||
* onMouseMove / onMouseUp track the drag.
|
||
* * wheel zoom -> capture-phase "wheel" listener on document with a
|
||
* manual hit-test (there is no node-level onWheel hook), so the
|
||
* canvas pan/zoom is preempted only while the cursor is on the preview.
|
||
* - The crop is stored as normalized floats in the (hidden) crop_x/y/w/h
|
||
* widgets so it serializes into the workflow and is usable via API.
|
||
* - "Original" aspect ratio (default): no crop area is drawn, drag/zoom are
|
||
* disabled and the (hidden) crop widgets hold the full frame, so the image
|
||
* passes through uncropped.
|
||
* - "Free (Custom)" aspect ratio: the box can be any size. Drag a corner
|
||
* handle to resize it freely (the opposite corner stays fixed), drag
|
||
* inside to move it; the wheel zooms keeping the box's own ratio.
|
||
* - 2.0 / Vue nodes mode (LiteGraph.vueNodesMode): the canvas preview
|
||
* widget does not exist there - the image is a DOM <img> (object-contain)
|
||
* inside the node's [data-node-id] element. The overlay is a positioned
|
||
* div on top of that image and pointer/wheel events are handled directly
|
||
* on the DOM element (the node's DOM is pointer-events-none, so without
|
||
* this the canvas pan/zoom would swallow the interaction). The same
|
||
* normalized crop state and the same crop_* widgets are used, so the
|
||
* behavior is identical in both modes.
|
||
*/
|
||
const LIRC = {
|
||
NODE_NAME: "LoadImageCrop",
|
||
PREVIEW_WIDGET: "$$canvas-image-preview",
|
||
CROP_KEYS: ["crop_x", "crop_y", "crop_w", "crop_h"],
|
||
FLIP_WIDGET: "flip",
|
||
TAG: "[LoadImageCrop]"
|
||
};
|
||
|
||
let lircApp = null;
|
||
|
||
/* ------------------------------ helpers ------------------------------ */
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
function widgetOf(node, name) {
|
||
return node.widgets ? node.widgets.find((w) => w.name === name) : null;
|
||
}
|
||
|
||
function getState(node) {
|
||
if (!node.__lirc) {
|
||
node.__lirc = {
|
||
inited: false,
|
||
crop: null, // {x,y,w,h} normalized 0..1 in image space
|
||
cropFromWorkflow: false,
|
||
rect: null, // cached displayed image rect, node-local {x,y,w,h}
|
||
dragging: false,
|
||
dragOff: null, // grab state: {nx, ny} pointer offset, {cx, cy, cw, ch} box at grab,
|
||
// {corner} "nw"/"ne"/"sw"/"se" while resizing (Free mode)
|
||
lastKey: null,
|
||
lastSrc: null, // last displayed image src (file-change detection)
|
||
originalImage: null, // the original image filename (before any flip)
|
||
};
|
||
}
|
||
return node.__lirc;
|
||
}
|
||
|
||
/** Current flip mode: "None", "Horizontal", or "Vertical". */
|
||
function flipOf(node) {
|
||
const w = widgetOf(node, LIRC.FLIP_WIDGET);
|
||
return w ? w.value : "None";
|
||
}
|
||
|
||
/**
|
||
* Flip the image via the backend endpoint and change the node's image input
|
||
* to the flipped file. The core then reloads the node with the flipped image,
|
||
* so the preview shows the flipped image and the crop works normally.
|
||
*/
|
||
async function applyFlip(node, flip) {
|
||
const st = getState(node);
|
||
const imgW = widgetOf(node, "image");
|
||
if (!imgW) return;
|
||
// Track the original image (before any flip).
|
||
if (!st.originalImage) st.originalImage = imgW.value;
|
||
if (flip === "None") {
|
||
// Revert to the original image.
|
||
const origCb = imgW.__lircOrigCallback;
|
||
imgW.value = st.originalImage;
|
||
if (typeof origCb === "function") origCb(st.originalImage);
|
||
markDirty(node);
|
||
try {
|
||
if (lircApp && lircApp.canvas) lircApp.canvas.setDirtyCanvas(true);
|
||
if (lircApp && lircApp.graph) lircApp.graph.setDirtyCanvas(true);
|
||
} catch (_) { /* ignore */ }
|
||
return;
|
||
}
|
||
// Flip the image in the browser (canvas), upload via ComfyUI's
|
||
// /upload/image endpoint, then change the image input to the flipped file.
|
||
try {
|
||
// Get the image already loaded by the core (node.imgs).
|
||
const src = (node.imgs && node.imgs[0]) || null;
|
||
if (!src) throw new Error("no loaded image (node.imgs empty)");
|
||
const img = await createImageBitmap(src);
|
||
// Flip via canvas.
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = flip === "Vertical" ? img.height : img.width;
|
||
canvas.height = flip === "Vertical" ? img.width : img.height;
|
||
const cctx = canvas.getContext("2d");
|
||
if (!cctx) throw new Error("no 2d context");
|
||
if (flip === "Horizontal") {
|
||
cctx.translate(canvas.width, 0);
|
||
cctx.scale(-1, 1);
|
||
} else {
|
||
cctx.translate(0, canvas.height);
|
||
cctx.scale(1, -1);
|
||
}
|
||
cctx.drawImage(img, 0, 0);
|
||
// Convert to a PNG blob.
|
||
const pngBlob = await new Promise((r) => canvas.toBlob(r, "image/png"));
|
||
if (!pngBlob) throw new Error("toBlob failed");
|
||
// Build the destination filename: {stem}-flipH.png / {stem}-flipV.png
|
||
const stem = st.originalImage.replace(/\.[^.]*$/, "");
|
||
const suffix = flip === "Horizontal" ? "flipH" : "flipV";
|
||
const destName = `${stem}-${suffix}.png`;
|
||
// Upload via ComfyUI's /upload/image endpoint.
|
||
const fd = new FormData();
|
||
fd.append("image", pngBlob, destName);
|
||
fd.append("overwrite", "true");
|
||
const upResp = await fetch("/upload/image", { method: "POST", body: fd });
|
||
const upData = await upResp.json();
|
||
if (upData.name == null) throw new Error("upload failed");
|
||
// Change the image input to the flipped file and trigger the core's
|
||
// original callback (the one that updates the preview).
|
||
const origCb = imgW.__lircOrigCallback;
|
||
imgW.value = upData.name;
|
||
if (typeof origCb === "function") origCb(upData.name);
|
||
markDirty(node);
|
||
try {
|
||
if (lircApp && lircApp.canvas) lircApp.canvas.setDirtyCanvas(true);
|
||
if (lircApp && lircApp.graph) lircApp.graph.setDirtyCanvas(true);
|
||
} catch (_) { /* ignore */ }
|
||
} catch (e) {
|
||
console.warn(LIRC.TAG, "flip failed:", e);
|
||
}
|
||
}
|
||
|
||
/** Aspect-ratio mode: null ("Original" = no crop), "free" (user-shaped
|
||
* box), or a positive number for the preset "W:H" entries. */
|
||
function ratioOf(node) {
|
||
const w = widgetOf(node, "aspect_ratio");
|
||
const v = w ? String(w.value) : "Original";
|
||
// "Original" (or no ratio match) -> no crop at all
|
||
if (/^\s*original\b/i.test(v)) return null;
|
||
// "Free (Custom)" -> user-drawn box, any shape
|
||
if (/^\s*free\b/i.test(v)) return "free";
|
||
// values look like "3:2 (Photo)" -> take the leading "W:H"
|
||
const m = v.match(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/);
|
||
if (m) {
|
||
const r = parseFloat(m[1]) / parseFloat(m[2]);
|
||
if (isFinite(r) && r > 0) return r;
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
function imgOf(node) {
|
||
const imgs = node.imgs;
|
||
if (!Array.isArray(imgs) || !imgs.length) return null;
|
||
const idx = Number.isInteger(node.imageIndex) ? node.imageIndex : 0;
|
||
return imgs[idx] || imgs[0];
|
||
}
|
||
|
||
function naturalSize(node) {
|
||
const img = imgOf(node);
|
||
if (!img) return null;
|
||
return { w: img.naturalWidth || img.width, h: img.naturalHeight || img.height, src: img.src };
|
||
}
|
||
|
||
/** Largest rect (normalized) with the given aspect ratio fitting the image. */
|
||
function maxFitRatio(ratio, imgW, imgH) {
|
||
if (ratio <= imgW / imgH) return { w: (ratio * imgH) / imgW, h: 1 };
|
||
return { w: 1, h: imgW / (ratio * imgH) };
|
||
}
|
||
|
||
/** The default Free box: a centered 90x90% area. With a full-frame box the
|
||
* corner handles would sit exactly on the image corners, i.e. on the node
|
||
* border, where the core canvas grabs the pointer for a node drag instead of
|
||
* letting us start the resize. Dragging a handle to the edge still reaches
|
||
* the exact full-frame crop (the clamping allows it). */
|
||
function freeCrop() {
|
||
return { x: 0.05, y: 0.05, w: 0.9, h: 0.9 };
|
||
}
|
||
|
||
function resetCrop(node) {
|
||
const st = getState(node);
|
||
const m = ratioOf(node);
|
||
if (m == null) {
|
||
// "Original": no crop
|
||
st.crop = null;
|
||
st.cropFromWorkflow = false;
|
||
return;
|
||
}
|
||
const n = naturalSize(node);
|
||
if (!n) return;
|
||
if (m === "free") {
|
||
// Free keeps the current box (e.g. left over from a preset) or starts
|
||
// with the default Free box; the user then shapes it with the handles.
|
||
st.crop = st.crop || freeCrop();
|
||
fitCrop(node, st.crop);
|
||
st.cropFromWorkflow = false;
|
||
return;
|
||
}
|
||
const fr = maxFitRatio(m, n.w, n.h);
|
||
st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h };
|
||
st.cropFromWorkflow = false;
|
||
}
|
||
|
||
/** Clamp crop to image bounds. Presets keep the ratio (height-driven);
|
||
* "free" keeps the drawn shape (min size + bounds only). */
|
||
function fitCrop(node, c) {
|
||
const n = naturalSize(node);
|
||
if (!n) return c;
|
||
const ratio = ratioOf(node);
|
||
if (ratio === "free") {
|
||
c.w = Math.min(Math.max(c.w, 0.02), 1);
|
||
c.h = Math.min(Math.max(c.h, 0.02), 1);
|
||
c.x = Math.min(Math.max(0, c.x), Math.max(0, 1 - c.w));
|
||
c.y = Math.min(Math.max(0, c.y), Math.max(0, 1 - c.h));
|
||
return c;
|
||
}
|
||
// normalized image space: w/h = ratio * (imgH/imgW)
|
||
const k = ratio * (n.h / n.w);
|
||
const fr = maxFitRatio(ratio, n.w, n.h);
|
||
c.h = Math.min(Math.max(c.h, 0.02), fr.h);
|
||
c.w = c.h * k;
|
||
c.x = Math.min(Math.max(0, c.x), Math.max(0, 1 - c.w));
|
||
c.y = Math.min(Math.max(0, c.y), Math.max(0, 1 - c.h));
|
||
return c;
|
||
}
|
||
|
||
function initCropFromWidgets(node) {
|
||
const st = getState(node);
|
||
const m = ratioOf(node);
|
||
if (m == null) {
|
||
// "Original": ignore any saved crop, always full frame
|
||
st.crop = null;
|
||
writeCrop(node);
|
||
return;
|
||
}
|
||
if (st.crop) return;
|
||
const g = (k) => {
|
||
const w = widgetOf(node, k);
|
||
return w && Number.isFinite(w.value) ? w.value : undefined;
|
||
};
|
||
let x = g("crop_x"), y = g("crop_y"), w = g("crop_w"), h = g("crop_h");
|
||
if (x == null || y == null || w == null || h == null) {
|
||
resetCrop(node);
|
||
writeCrop(node);
|
||
} else if (m !== "free" && x === 0 && y === 0 && w === 1 && h === 1) {
|
||
// Python defaults == "no crop saved yet" -> derive from the ratio
|
||
// (a full-frame Free box is a legitimate saved shape, keep it)
|
||
resetCrop(node);
|
||
writeCrop(node);
|
||
} else {
|
||
st.crop = { x, y, w, h };
|
||
st.cropFromWorkflow = true;
|
||
fitCrop(node, st.crop);
|
||
}
|
||
}
|
||
|
||
function writeCrop(node) {
|
||
const st = getState(node);
|
||
// no crop yet: full frame for "Original"/presets, default Free box for Free
|
||
const c = st.crop || (ratioOf(node) === "free" ? freeCrop() : { x: 0, y: 0, w: 1, h: 1 });
|
||
// "Original" -> write the full frame so a run without frontend interaction
|
||
// returns the image as-is
|
||
const map = { crop_x: "x", crop_y: "y", crop_w: "w", crop_h: "h" };
|
||
for (const [key, prop] of Object.entries(map)) {
|
||
const w = widgetOf(node, key);
|
||
if (w) w.value = c[prop];
|
||
}
|
||
}
|
||
|
||
function markDirty(node) {
|
||
try {
|
||
if (node.graph && typeof node.graph.setDirtyCanvas === "function") {
|
||
node.graph.setDirtyCanvas(true);
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
/** Is the node-local point p inside the displayed crop box (rect + crop)? */
|
||
function insideCropBox(p, st) {
|
||
const r = st.rect, c = st.crop;
|
||
if (!r || !c) return false;
|
||
const nx = (p[0] - r.x) / r.w;
|
||
const ny = (p[1] - r.y) / r.h;
|
||
return nx >= c.x && nx <= c.x + c.w && ny >= c.y && ny <= c.y + c.h;
|
||
}
|
||
|
||
/** Free mode: the crop-box corner the node-local point p is near (within a
|
||
* screen-pixel threshold, so it works at any zoom), or null. */
|
||
function cornerAt(p, node) {
|
||
const st = node.__lirc;
|
||
const nat = naturalSize(node);
|
||
const r = st && st.rect, c = st && st.crop;
|
||
if (!r || !c || !nat) return null;
|
||
// screen px -> node-local units through the canvas zoom
|
||
const z = (lircApp && lircApp.canvas && lircApp.canvas.ds && lircApp.canvas.ds.scale) || 1;
|
||
const th = 18 / z; // 18 screen px, in node-local units
|
||
const cx = r.x + c.x * r.w, cy = r.y + c.y * r.h;
|
||
const cw = c.w * r.w, ch = c.h * r.h;
|
||
const pts = { nw: [cx, cy], ne: [cx + cw, cy], sw: [cx, cy + ch], se: [cx + cw, cy + ch] };
|
||
let best = th, hit = null;
|
||
for (const k of Object.keys(pts)) {
|
||
const d = Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]);
|
||
if (d < best) { best = d; hit = k; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
function pointInRect(p, r) {
|
||
return p[0] >= r.x && p[0] <= r.x + r.w && p[1] >= r.y && p[1] <= r.y + r.h;
|
||
}
|
||
|
||
function nodeLocalPos(node, canvas) {
|
||
// graph_mouse is in the same coordinate space as node.pos (the core
|
||
// compares graph_mouse against node.pos + offsets)
|
||
const gm = canvas && canvas.graph_mouse;
|
||
if (!gm) return null;
|
||
return [gm[0] - node.pos[0], gm[1] - node.pos[1]];
|
||
}
|
||
|
||
/**
|
||
* Node-local position of a DOM pointer/mouse event. Uses clientX/clientY
|
||
* (no assumptions about which element received the event), converted with
|
||
* the canvas' viewport math. Returns [x, y] in node-local canvas units.
|
||
*/
|
||
function eventToNodeLocal(e, node, canvas) {
|
||
if (!e || !canvas || typeof canvas.clientPosToCanvasPos !== "function") {
|
||
return null;
|
||
}
|
||
try {
|
||
const p = canvas.clientPosToCanvasPos([e.clientX, e.clientY]);
|
||
if (!p) return null;
|
||
return [p[0] - node.pos[0], p[1] - node.pos[1]];
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/* --------------------------- overlay drawing -------------------------- */
|
||
|
||
/**
|
||
* Called from the wrapped widget.drawWidget, AFTER the core's renderPreview
|
||
* ran. Mirrors the core's single-image fit math exactly:
|
||
* dw = options.width, dh = computedHeight - d, d = 15 when the
|
||
* "Comfy.Node.AllowImageSizeDraw" setting is on (default), 0 otherwise.
|
||
* scale = min(dw/nw, dh/nh, 1); x = (dw-w)/2; y = (dh-h)/2 + widget.y
|
||
*/
|
||
function sizeDrawOffset() {
|
||
try {
|
||
// The setting store is not exposed globally; default is true (15).
|
||
// If a future build exposes it, honor it.
|
||
const st = window.comfyAPI?.settingStore;
|
||
if (st && typeof st.get === "function") {
|
||
return st.get("Comfy.Node.AllowImageSizeDraw") === false ? 0 : 15;
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
return 15;
|
||
}
|
||
|
||
function scheduleOverlay(node, ctx, widget, width, imgsArg) {
|
||
const st = getState(node);
|
||
const imgs = imgsArg || node.imgs;
|
||
if (!Array.isArray(imgs) || !imgs.length || !node.size) {
|
||
st.rect = null;
|
||
return;
|
||
}
|
||
let idx = node.imageIndex;
|
||
if (imgs.length === 1 && !idx) idx = 0;
|
||
const img = imgs[idx];
|
||
if (!img) { st.rect = null; return; }
|
||
// Keep the node visible to the core isImageNode() gate so the
|
||
// "Open in MaskEditor | Image Canvas" context menu entry stays available.
|
||
node.previewMediaType = "image";
|
||
const nw = img.naturalWidth || img.width;
|
||
const nh = img.naturalHeight || img.height;
|
||
if (!nw || !nh) { st.rect = null; return; }
|
||
|
||
// grid mode (multiple images, no selection) -> the core draws a grid we
|
||
// don't model; skip the overlay until the user picks one cell
|
||
if (node.imageIndex == null) {
|
||
st.rect = null;
|
||
return;
|
||
}
|
||
|
||
const dw = width;
|
||
const dh = (widget.computedHeight || 0) - sizeDrawOffset();
|
||
if (!(dw > 0) || !(dh > 0)) { st.rect = null; return; }
|
||
|
||
const scale = Math.min(dw / nw, dh / nh, 1);
|
||
const w = nw * scale;
|
||
const h = nh * scale;
|
||
const x = (dw - w) / 2;
|
||
const y = (dh - h) / 2 + (widget.y || 0);
|
||
st.rect = { x, y, w, h };
|
||
|
||
// If the displayed image or the ratio changed -> (re)initialize the crop.
|
||
const key = `${img.src}|${ratioOf(node)}`;
|
||
if (st.lastKey !== key) {
|
||
st.lastKey = key;
|
||
// a Free crop starts full-frame on a new image
|
||
if (ratioOf(node) === "free" && st.lastSrc !== img.src) st.crop = null;
|
||
st.lastSrc = img.src;
|
||
if (!st.cropFromWorkflow) {
|
||
resetCrop(node);
|
||
// write the crop into the (hidden) widgets so a run without mouse
|
||
// interaction crops exactly what the overlay shows
|
||
writeCrop(node);
|
||
} else {
|
||
if (st.crop) {
|
||
fitCrop(node, st.crop);
|
||
} else {
|
||
resetCrop(node);
|
||
}
|
||
writeCrop(node);
|
||
}
|
||
st.cropFromWorkflow = false;
|
||
}
|
||
|
||
const transform = ctx.getTransform();
|
||
const filter = ctx.filter;
|
||
// Queued after the core's deferred drawImage microtask (FIFO) -> the
|
||
// overlay is painted over the image, before the browser paints the frame.
|
||
queueMicrotask(() => drawOverlay(node, ctx, transform, filter));
|
||
}
|
||
|
||
function drawOverlay(node, ctx, transform, filter) {
|
||
const st = node.__lirc;
|
||
if (!st || !st.rect || !st.crop) return;
|
||
const { x: px, y: py, w: pw, h: ph } = st.rect;
|
||
const c = st.crop;
|
||
const rx = px + c.x * pw;
|
||
const ry = py + c.y * ph;
|
||
const rw = c.w * pw;
|
||
const rh = c.h * ph;
|
||
try {
|
||
ctx.save();
|
||
ctx.setTransform(transform);
|
||
ctx.filter = filter;
|
||
// dim everything outside the crop, clipped to the image rect
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(px, py, pw, ph);
|
||
ctx.clip();
|
||
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
||
ctx.fillRect(px, py, pw, ry - py);
|
||
ctx.fillRect(px, ry + rh, pw, py + ph - ry - rh);
|
||
ctx.fillRect(px, ry, rx - px, rh);
|
||
ctx.fillRect(rx + rw, ry, px + pw - rx - rw, rh);
|
||
ctx.restore();
|
||
// crop border
|
||
ctx.strokeStyle = "#ff4040";
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(rx + 1, ry + 1, Math.max(2, rw - 2), Math.max(2, rh - 2));
|
||
// Free-mode corner handles (white squares on the box corners)
|
||
if (ratioOf(node) === "free") {
|
||
const hs = 7;
|
||
const corners = [
|
||
[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]
|
||
];
|
||
for (const [hx, hy] of corners) {
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||
ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||
}
|
||
}
|
||
// output size label
|
||
const img = imgOf(node);
|
||
if (img) {
|
||
const ow = Math.round(c.w * (img.naturalWidth || img.width));
|
||
const oh = Math.round(c.h * (img.naturalHeight || img.height));
|
||
ctx.font = "11px monospace";
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
const label = `${ow} × ${oh}`;
|
||
let ly = ry - 6;
|
||
if (ly < py + 12) ly = ry + 14;
|
||
ctx.fillStyle = "rgba(0,0,0,0.8)";
|
||
const tw = ctx.measureText(label).width;
|
||
ctx.fillRect(rx + 4, ly - 11, tw + 8, 14);
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.fillText(label, rx + 8, ly);
|
||
}
|
||
ctx.restore();
|
||
} catch (e) {
|
||
console.warn(LIRC.TAG, "overlay draw failed", e);
|
||
}
|
||
}
|
||
|
||
/* --------------------------- crop drag -------------------------------- */
|
||
|
||
/** Shared drag-end: finalize the crop (also called from the pointer session). */
|
||
function endCropDrag(node) {
|
||
const s = getState(node);
|
||
if (s.dragging) {
|
||
s.dragging = false;
|
||
s.dragOff = null;
|
||
if (s.crop) {
|
||
fitCrop(node, s.crop);
|
||
writeCrop(node);
|
||
}
|
||
markDirty(node);
|
||
}
|
||
try {
|
||
const c = lircApp && lircApp.canvas && lircApp.canvas.canvas;
|
||
if (c) c.style.cursor = "";
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
/** Apply one drag step: move the box (grab point follows the pointer), or
|
||
* resize it from a corner when corner is set (the opposite corner stays
|
||
* fixed). p is the pointer in normalized image space. */
|
||
function stepCropDrag(node, off, p) {
|
||
const s = getState(node);
|
||
if (!s.crop) return;
|
||
if (off.corner) {
|
||
const fx = off.corner === "nw" || off.corner === "sw" ? off.cx + off.cw : off.cx;
|
||
const fy = off.corner === "nw" || off.corner === "ne" ? off.cy + off.ch : off.cy;
|
||
s.crop.x = Math.min(fx, p[0]);
|
||
s.crop.y = Math.min(fy, p[1]);
|
||
s.crop.w = Math.abs(p[0] - fx);
|
||
s.crop.h = Math.abs(p[1] - fy);
|
||
} else {
|
||
s.crop.x = p[0] - off.nx + off.cx;
|
||
s.crop.y = p[1] - off.ny + off.cy;
|
||
}
|
||
fitCrop(node, s.crop);
|
||
writeCrop(node);
|
||
markDirty(node);
|
||
}
|
||
|
||
/** Move (or resize, in Free mode) the crop toward the DOM pointer event. */
|
||
function dragCropTo(e, node, canvas) {
|
||
const s = getState(node);
|
||
if (!s.dragging || !s.rect || !s.crop || !s.dragOff) return;
|
||
const p = eventToNodeLocal(e, node, canvas) || nodeLocalPos(node, canvas);
|
||
if (!p) return;
|
||
const r = s.rect;
|
||
stepCropDrag(node, s.dragOff, [(p[0] - r.x) / r.w, (p[1] - r.y) / r.h]);
|
||
}
|
||
|
||
/* --------------------------- widget wrapping -------------------------- */
|
||
|
||
function wrapPreviewWidget(node) {
|
||
const w = widgetOf(node, LIRC.PREVIEW_WIDGET);
|
||
if (!w || w.__lircWrapped) return;
|
||
w.__lircWrapped = true;
|
||
|
||
const origDraw = w.drawWidget ? w.drawWidget.bind(w) : null;
|
||
w.drawWidget = (ctx, options) => {
|
||
try {
|
||
if (origDraw) origDraw(ctx, options);
|
||
} catch (e) {
|
||
console.warn(LIRC.TAG, "core preview draw", e);
|
||
}
|
||
try {
|
||
scheduleOverlay(node, ctx, w, options && options.width, options && options.previewImages);
|
||
} catch (e) {
|
||
console.warn(LIRC.TAG, "overlay schedule", e);
|
||
}
|
||
};
|
||
|
||
const origPointerDown = w.onPointerDown ? w.onPointerDown.bind(w) : null;
|
||
w.onPointerDown = (pointer, nodeArg, canvas) => {
|
||
const st = getState(node);
|
||
// Prefer the DOM event (clientX/Y -> viewport math): the mini-canvas
|
||
// pointer path in the Vue frontend overwrites graph_mouse with
|
||
// widget-row-relative offsets, so graph_mouse is not reliable here.
|
||
const ev = pointer && pointer.eDown;
|
||
const p = (ev ? eventToNodeLocal(ev, node, canvas) : null) || nodeLocalPos(node, canvas);
|
||
// Free mode: a hit on a box corner resizes the box (opposite corner
|
||
// fixed); elsewhere inside the box it moves as before
|
||
const corner = st.rect && st.crop && p && ratioOf(node) === "free" ? cornerAt(p, node) : null;
|
||
if (st.rect && st.crop && p && (corner || insideCropBox(p, st))) {
|
||
// consume: crop drag instead of node drag; remember where (in
|
||
// normalized image space) the grab happened so the rectangle follows
|
||
// the pointer without jumping
|
||
const nx = (p[0] - st.rect.x) / st.rect.w;
|
||
const ny = (p[1] - st.rect.y) / st.rect.h;
|
||
st.dragging = true;
|
||
st.dragOff = { nx, ny, cx: st.crop.x, cy: st.crop.y, cw: st.crop.w, ch: st.crop.h, corner };
|
||
// Drive the drag from the pointer session itself. In the Vue frontend
|
||
// the widget's mini-canvas captures the pointer and stops propagation,
|
||
// so the node-level onMouseDown/onMouseMove are never fired there;
|
||
// the session's onDrag is the only reliable path (legacy mode keeps
|
||
// node.onMouseMove as a fallback, same absolute math).
|
||
if (pointer) {
|
||
pointer.onDragStart = () => {};
|
||
pointer.onDrag = (e) => dragCropTo(e, node, canvas);
|
||
pointer.onDragEnd = () => endCropDrag(node);
|
||
}
|
||
try {
|
||
const cur = corner ? (corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize") : "move";
|
||
if (canvas && canvas.canvas) canvas.canvas.style.cursor = cur;
|
||
} catch (_) { /* ignore */ }
|
||
return true;
|
||
}
|
||
// outside the crop box -> keep the core canvas behavior (node
|
||
// select/move), same as the official Load Image
|
||
if (origPointerDown) return origPointerDown(pointer, nodeArg, canvas);
|
||
return false;
|
||
};
|
||
}
|
||
|
||
/* --------------------------- node init/hooking ------------------------ */
|
||
|
||
function initNode(node) {
|
||
try {
|
||
if (!node || node.type !== LIRC.NODE_NAME) return;
|
||
const st = getState(node);
|
||
// remove unlinked FLOAT slots: this node has no linkable inputs
|
||
const inputs = node.inputs;
|
||
if (Array.isArray(inputs)) {
|
||
for (let i = inputs.length - 1; i >= 0; i--) {
|
||
const slot = inputs[i];
|
||
if (slot && slot.type === "FLOAT" && slot.link == null) {
|
||
try {
|
||
if (typeof node.removeInput === "function") node.removeInput(slot);
|
||
} catch (_) { /* ignore */ }
|
||
if (Array.isArray(node.inputs)) {
|
||
const j = node.inputs.indexOf(slot);
|
||
if (j !== -1) node.inputs.splice(j, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!st.inited) {
|
||
st.inited = true;
|
||
// hide the crop_* fields on the node (they still serialize); the 2.0
|
||
// renderer filters on options.hidden, classic litegraph on hidden
|
||
for (const key of LIRC.CROP_KEYS) {
|
||
const w = widgetOf(node, key);
|
||
if (!w) continue;
|
||
w.hidden = true;
|
||
if (!w.options || typeof w.options !== "object") w.options = {};
|
||
w.options.hidden = true;
|
||
}
|
||
// keep the aspect_ratio widget's change in sync with the crop
|
||
const ar = widgetOf(node, "aspect_ratio");
|
||
if (ar && !ar.__lircBound) {
|
||
ar.__lircBound = true;
|
||
const prev = ar.callback;
|
||
ar.callback = (v) => {
|
||
const n = naturalSize(node);
|
||
const r = ratioOf(node);
|
||
if (r == null) {
|
||
// "Original": drop the crop area entirely
|
||
st.crop = null;
|
||
} else if (r === "free") {
|
||
// Free keeps the box the user has drawn (or the default Free box)
|
||
if (n) {
|
||
st.crop = st.crop || freeCrop();
|
||
fitCrop(node, st.crop);
|
||
}
|
||
} else if (n) {
|
||
const fr = maxFitRatio(r, n.w, n.h);
|
||
st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h };
|
||
}
|
||
st.cropFromWorkflow = false;
|
||
writeCrop(node);
|
||
if (typeof prev === "function") prev(v);
|
||
if (isVueMode()) layoutVueOverlay(node);
|
||
markDirty(node);
|
||
};
|
||
}
|
||
// image change -> refresh combo values are handled by the core; we just
|
||
// re-sync the crop when the displayed image changes (done in overlay).
|
||
const imgW = widgetOf(node, "image");
|
||
if (imgW && !imgW.__lircBound) {
|
||
imgW.__lircBound = true;
|
||
const prev = imgW.callback;
|
||
imgW.__lircOrigCallback = prev;
|
||
imgW.callback = (v) => {
|
||
if (typeof prev === "function") prev(v);
|
||
// Update originalImage when the user picks a non-flipped image
|
||
// (i.e. the value does not match our flip filename pattern).
|
||
const st = getState(node);
|
||
if (!/-flip[HV]\.(png|jpg|jpeg|webp)$/i.test(String(v))) {
|
||
st.originalImage = v;
|
||
}
|
||
// let the core load the image; crop resets when the overlay sees a
|
||
// new image key (handled in scheduleOverlay)
|
||
};
|
||
}
|
||
// flip widget: when it changes, flip the image via the backend and
|
||
// change the image input to the flipped file.
|
||
const flipW = widgetOf(node, LIRC.FLIP_WIDGET);
|
||
if (flipW && !flipW.__lircBound) {
|
||
flipW.__lircBound = true;
|
||
const prev = flipW.callback;
|
||
flipW.callback = (v) => {
|
||
if (typeof prev === "function") prev(v);
|
||
applyFlip(node, v);
|
||
};
|
||
}
|
||
// mouse tracking (node-level; fallback for non-Vue frontends plus
|
||
// cursor feedback whenever the mouse is over the node)
|
||
node.onMouseMove = (e, pos) => {
|
||
const s = getState(node);
|
||
if (s.dragging && s.rect && s.crop && s.dragOff) {
|
||
const r = s.rect;
|
||
// pointer in normalized image space
|
||
stepCropDrag(node, s.dragOff, [(pos[0] - r.x) / r.w, (pos[1] - r.y) / r.h]);
|
||
} else if (s.rect) {
|
||
let cur = "";
|
||
if (s.crop) {
|
||
const corner = ratioOf(node) === "free" ? cornerAt(pos, node) : null;
|
||
if (corner) {
|
||
cur = corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize";
|
||
} else if (insideCropBox(pos, s)) {
|
||
cur = "move";
|
||
}
|
||
}
|
||
try {
|
||
const c = lircApp && lircApp.canvas && lircApp.canvas.canvas;
|
||
if (c) c.style.cursor = cur;
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
};
|
||
node.onMouseUp = () => endCropDrag(node);
|
||
node.onMouseLeave = () => endCropDrag(node);
|
||
// the core adds the preview widget asynchronously (when the image has
|
||
// loaded), so re-check every frame and wrap it lazily
|
||
const prevDrawFg = node.onDrawForeground;
|
||
node.onDrawForeground = (ctx, canvas, canvasEl) => {
|
||
try { wrapPreviewWidget(node); } catch (_) { /* ignore */ }
|
||
if (typeof prevDrawFg === "function") prevDrawFg.call(node, ctx, canvas, canvasEl);
|
||
};
|
||
// context menu: the 2.0 menu already contains the core "Paste Image"
|
||
// entry, so ours is added only in classic mode; both entries drive
|
||
// the native node.pasteFile / node.pasteFiles methods.
|
||
// Chain into the core implementation so its entries (Open Image,
|
||
// Save Image, Bypass, Clipspace, Open in MaskEditor | Image Canvas,
|
||
// ...) are preserved on this node. The core mutates `options` (the
|
||
// base list) in place, so we return ONLY our own additions — the
|
||
// canvas already owns the base list. Concatenating it here would
|
||
// duplicate every base entry on builds whose core returns the
|
||
// mutated list (e.g. ComfyUI nightly/dev).
|
||
const coreExtraMenu = node.getExtraMenuOptions;
|
||
node.getExtraMenuOptions = (canvas, options) => {
|
||
const items = [];
|
||
if (!isVueMode()) {
|
||
items.push({
|
||
content: "Paste Image from Clipboard",
|
||
callback: () => pasteFromClipboard(node)
|
||
});
|
||
}
|
||
if (typeof coreExtraMenu === "function") {
|
||
try {
|
||
coreExtraMenu.call(node, canvas, options || []);
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
return items;
|
||
};
|
||
initCropFromWidgets(node);
|
||
}
|
||
wrapPreviewWidget(node);
|
||
} catch (e) {
|
||
console.warn(LIRC.TAG, "initNode", e);
|
||
}
|
||
}
|
||
|
||
/* --------------------- 2.0 / Vue nodes mode (DOM) -------------------- */
|
||
|
||
function isVueMode() {
|
||
try {
|
||
return typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode === true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Locate the 2.0 preview DOM: panel + <img> inside the node element. */
|
||
function vuePartsOf(node) {
|
||
const root = document.querySelector(`[data-node-id="${node.id}"]`);
|
||
if (!root) return null;
|
||
const img = root.querySelector('div[class*="image-preview"] img');
|
||
if (!img) return null;
|
||
const panel =
|
||
img.closest('div[class*="group/panel"]') || img.parentElement;
|
||
return { img, panel };
|
||
}
|
||
|
||
function removeVueOverlay(node) {
|
||
const st = node.__lirc;
|
||
if (st && st.vue && st.vue.ov && st.vue.ov.parentElement) {
|
||
st.vue.ov.parentElement.removeChild(st.vue.ov);
|
||
}
|
||
if (st && st.vue && st.vue.ro) {
|
||
try { st.vue.ro.disconnect(); } catch (_) { /* ignore */ }
|
||
}
|
||
if (st) st.vue = null;
|
||
}
|
||
|
||
/**
|
||
* Re-sync the DOM overlay: (re)create it when the panel or the <img> changed
|
||
* under us (Vue re-renders replace DOM elements), re-init the crop on image
|
||
* or ratio change, and position the crop box over the object-contain area.
|
||
*/
|
||
function layoutVueOverlay(node) {
|
||
const st = getState(node);
|
||
const parts = vuePartsOf(node);
|
||
if (!parts || !parts.img.complete || !parts.img.naturalWidth) return;
|
||
|
||
// Keep the node visible to the core image gates (isImageNode, mask
|
||
// editor, open/copy image) which read node.imgs / previewMediaType.
|
||
if (!Array.isArray(node.imgs) || !node.imgs.length) {
|
||
node.imgs = [parts.img];
|
||
}
|
||
node.previewMediaType = "image";
|
||
|
||
const vw = parts.img.naturalWidth;
|
||
const vh = parts.img.naturalHeight;
|
||
const key = `${parts.img.src}|${ratioOf(node)}|${vw}x${vh}`;
|
||
if (st.lastKey !== key) {
|
||
st.lastKey = key;
|
||
const r = ratioOf(node);
|
||
if (r == null) {
|
||
st.crop = null;
|
||
} else if (r === "free") {
|
||
// Free: the default Free box on a new image, keep the drawn box on a
|
||
// ratio-only change; clamp to bounds + min size
|
||
if (st.lastSrc !== parts.img.src || !st.crop) st.crop = freeCrop();
|
||
st.crop.w = Math.min(Math.max(st.crop.w, 0.02), 1);
|
||
st.crop.h = Math.min(Math.max(st.crop.h, 0.02), 1);
|
||
st.crop.x = Math.min(Math.max(0, st.crop.x), Math.max(0, 1 - st.crop.w));
|
||
st.crop.y = Math.min(Math.max(0, st.crop.y), Math.max(0, 1 - st.crop.h));
|
||
} else if (!st.cropFromWorkflow) {
|
||
const fr = maxFitRatio(r, vw, vh);
|
||
st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h };
|
||
} else if (st.crop) {
|
||
// same clamp as the classic path (fitCrop): keep the saved size and
|
||
// position, clamped to the image bounds
|
||
const k = r * (vh / vw);
|
||
const fr = maxFitRatio(r, vw, vh);
|
||
const h = Math.min(Math.max(st.crop.h, 0.02), fr.h);
|
||
const w = h * k;
|
||
st.crop = {
|
||
x: Math.min(Math.max(0, st.crop.x), Math.max(0, 1 - w)),
|
||
y: Math.min(Math.max(0, st.crop.y), Math.max(0, 1 - h)),
|
||
w,
|
||
h,
|
||
};
|
||
} else {
|
||
// no saved box (e.g. the ratio was Original before): derive the default
|
||
const fr = maxFitRatio(r, vw, vh);
|
||
st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h };
|
||
}
|
||
st.lastSrc = parts.img.src;
|
||
st.cropFromWorkflow = false;
|
||
writeCrop(node);
|
||
}
|
||
|
||
const same =
|
||
st.vue && st.vue.panel === parts.panel && st.vue.img === parts.img;
|
||
if (!same) buildVueOverlay(node, parts);
|
||
if (!st.vue) return;
|
||
|
||
const ov = st.vue.ov;
|
||
const box = st.vue.box;
|
||
const label = st.vue.label;
|
||
const c = st.crop;
|
||
if (!c) {
|
||
ov.style.display = "none";
|
||
return;
|
||
}
|
||
ov.style.display = "";
|
||
// displayed image area inside the panel (object-contain)
|
||
const pw = parts.panel.clientWidth || 1;
|
||
const ph = parts.panel.clientHeight || 1;
|
||
const s = Math.min(pw / vw, ph / vh);
|
||
const dw = vw * s;
|
||
const dh = vh * s;
|
||
const ox = (pw - dw) / 2;
|
||
const oy = (ph - dh) / 2;
|
||
const l = ((ox + c.x * dw) / pw) * 100;
|
||
const t = ((oy + c.y * dh) / ph) * 100;
|
||
const ww = (c.w * dw) / pw * 100;
|
||
const hh = (c.h * dh) / ph * 100;
|
||
box.style.left = l + "%";
|
||
box.style.top = t + "%";
|
||
box.style.width = ww + "%";
|
||
box.style.height = hh + "%";
|
||
const ow = Math.round(c.w * vw);
|
||
const oh = Math.round(c.h * vh);
|
||
label.textContent = `${ow} × ${oh}`;
|
||
// room for the label inside the box?
|
||
label.style.display = box.offsetHeight > 18 ? "" : "none";
|
||
// corner handles are only useful in Free mode (presets keep their ratio)
|
||
const hs = st.vue.handles;
|
||
if (hs) for (const h of hs) h.style.display = ratioOf(node) === "free" ? "" : "none";
|
||
}
|
||
|
||
function buildVueOverlay(node, parts) {
|
||
const st = getState(node);
|
||
if (st.vue) removeVueOverlay(node);
|
||
|
||
const ov = document.createElement("div");
|
||
ov.dataset.lircOverlay = "1";
|
||
ov.style.cssText =
|
||
"position:absolute;inset:0;z-index:1;touch-action:none;cursor:default;";
|
||
const box = document.createElement("div");
|
||
box.style.cssText =
|
||
"position:absolute;box-sizing:border-box;border:2px solid #ff4040;box-shadow:0 0 0 4000px rgba(0,0,0,0.55);";
|
||
const label = document.createElement("div");
|
||
label.style.cssText =
|
||
"position:absolute;left:4px;top:2px;font:11px monospace;color:#fff;background:rgba(0,0,0,0.8);padding:0 4px;white-space:nowrap;";
|
||
box.appendChild(label);
|
||
// Free-mode corner handles (shown only while aspect_ratio is Free):
|
||
// dragging one resizes the box freely, the opposite corner stays fixed
|
||
const handles = [];
|
||
for (const corner of ["nw", "ne", "sw", "se"]) {
|
||
const h = document.createElement("div");
|
||
h.style.cssText =
|
||
"position:absolute;box-sizing:border-box;width:12px;height:12px;" +
|
||
"background:#fff;border:1px solid #ff4040;display:none;" +
|
||
(corner.startsWith("n") ? "top:-6px;" : "bottom:-6px;") +
|
||
(corner.endsWith("w") ? "left:-6px;" : "right:-6px;");
|
||
box.appendChild(h);
|
||
handles.push(h);
|
||
}
|
||
ov.appendChild(box);
|
||
parts.panel.appendChild(ov);
|
||
|
||
// pointer in normalized image space, mapped the same way as the overlay
|
||
// box (object-contain inside the panel). Using the <img> element rect
|
||
// directly drifts when the element doesn't exactly cover the contained
|
||
// image area (the dragged corner then lags the pointer).
|
||
const imgPos = (e) => {
|
||
const pr = parts.panel.getBoundingClientRect();
|
||
const vw = parts.img.naturalWidth || 1;
|
||
const vh = parts.img.naturalHeight || 1;
|
||
const pw = pr.width || 1;
|
||
const ph = pr.height || 1;
|
||
const s = Math.min(pw / vw, ph / vh);
|
||
const dw = vw * s, dh = vh * s;
|
||
const ox = (pw - dw) / 2, oy = (ph - dh) / 2;
|
||
return {
|
||
nx: (e.clientX - pr.left - ox) / (dw || 1),
|
||
ny: (e.clientY - pr.top - oy) / (dh || 1)
|
||
};
|
||
};
|
||
// displayed image width in screen px (for screen-pixel paddings)
|
||
const imgScreenW = () => {
|
||
const pr = parts.panel.getBoundingClientRect();
|
||
const s = Math.min((pr.width || 1) / (parts.img.naturalWidth || 1), (pr.height || 1) / (parts.img.naturalHeight || 1));
|
||
return (parts.img.naturalWidth || 1) * s;
|
||
};
|
||
// inside the crop box; in Free mode the test is padded (screen px) so
|
||
// the corner handles stay grabbable from outside the box
|
||
const inBox = (nx, ny, c) => {
|
||
if (!c) return false;
|
||
// Free: pad the hit area by a few screen px so the corner handles stay
|
||
// grabbable from outside the box
|
||
const m = ratioOf(node) === "free" ? 8 / (imgScreenW() || 1) : 0;
|
||
return nx >= c.x - m && nx <= c.x + c.w + m && ny >= c.y - m && ny <= c.y + c.h + m;
|
||
};
|
||
// the crop-box corner near the client point (Free mode), or null
|
||
const cornerAtClient = (cx, cy) => {
|
||
const br = box.getBoundingClientRect();
|
||
const pts = {
|
||
nw: [br.left, br.top], ne: [br.right, br.top],
|
||
sw: [br.left, br.bottom], se: [br.right, br.bottom]
|
||
};
|
||
let best = 18, hit = null;
|
||
for (const k of Object.keys(pts)) {
|
||
const d = Math.hypot(cx - pts[k][0], cy - pts[k][1]);
|
||
if (d < best) { best = d; hit = k; }
|
||
}
|
||
return hit;
|
||
};
|
||
|
||
let drag = null;
|
||
ov.addEventListener("pointerdown", (e) => {
|
||
const s = getState(node);
|
||
const { nx, ny } = imgPos(e);
|
||
if (!s.crop || !inBox(nx, ny, s.crop)) return;
|
||
// consume only inside the crop box: outside, let the event reach the
|
||
// panel (2.0 opens the mask editor on preview click)
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// Free mode: a hit on a box corner resizes it (opposite corner fixed)
|
||
const corner = ratioOf(node) === "free" ? cornerAtClient(e.clientX, e.clientY) : null;
|
||
drag = { nx, ny, cx: s.crop.x, cy: s.crop.y, cw: s.crop.w, ch: s.crop.h, corner };
|
||
s.dragging = true;
|
||
try { ov.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||
ov.style.cursor = corner
|
||
? (corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize")
|
||
: "move";
|
||
});
|
||
ov.addEventListener("pointermove", (e) => {
|
||
const s = getState(node);
|
||
const { nx, ny } = imgPos(e);
|
||
if (drag && s.crop) {
|
||
if (drag.corner) {
|
||
// resize: the opposite corner (from the box at drag start) stays
|
||
// fixed, the box grows/shrinks toward the pointer
|
||
const fx = drag.corner === "nw" || drag.corner === "sw" ? drag.cx + drag.cw : drag.cx;
|
||
const fy = drag.corner === "nw" || drag.corner === "ne" ? drag.cy + drag.ch : drag.cy;
|
||
s.crop.x = Math.min(fx, nx);
|
||
s.crop.y = Math.min(fy, ny);
|
||
s.crop.w = Math.abs(nx - fx);
|
||
s.crop.h = Math.abs(ny - fy);
|
||
} else {
|
||
s.crop.x = nx - drag.nx + drag.cx;
|
||
s.crop.y = ny - drag.ny + drag.cy;
|
||
}
|
||
fitCrop(node, s.crop);
|
||
writeCrop(node);
|
||
layoutVueOverlay(node);
|
||
} else {
|
||
let cur = "default";
|
||
if (inBox(nx, ny, s.crop)) {
|
||
cur = "move";
|
||
if (ratioOf(node) === "free" && s.crop) {
|
||
const cn = cornerAtClient(e.clientX, e.clientY);
|
||
if (cn) cur = cn === "nw" || cn === "se" ? "nwse-resize" : "nesw-resize";
|
||
}
|
||
}
|
||
ov.style.cursor = cur;
|
||
}
|
||
});
|
||
const endDrag = (e) => {
|
||
if (!drag) return;
|
||
drag = null;
|
||
const s = getState(node);
|
||
s.dragging = false;
|
||
if (s.crop) {
|
||
fitCrop(node, s.crop);
|
||
writeCrop(node);
|
||
}
|
||
try { ov.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||
ov.style.cursor = "default";
|
||
};
|
||
ov.addEventListener("pointerup", endDrag);
|
||
ov.addEventListener("pointercancel", endDrag);
|
||
ov.addEventListener("wheel", (e) => {
|
||
const s = getState(node);
|
||
if (!s.crop) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const n = { w: parts.img.naturalWidth, h: parts.img.naturalHeight };
|
||
const r = ratioOf(node);
|
||
if (r == null) return;
|
||
// Free: zoom keeps the box's own (drawn) ratio
|
||
const rr = r === "free" ? (s.crop.w * n.w) / (s.crop.h * n.h) : r;
|
||
const fr = maxFitRatio(rr, n.w, n.h);
|
||
const c = s.crop;
|
||
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||
let newH = c.h * factor;
|
||
if (newH > fr.h) newH = fr.h;
|
||
if (newH < 0.02) newH = 0.02;
|
||
if (Math.abs(newH - c.h) <= 1e-6) return;
|
||
const k = rr * (n.h / n.w);
|
||
const newW = newH * k;
|
||
const { nx, ny } = imgPos(e);
|
||
const cx = nx - c.x;
|
||
const cy = ny - c.y;
|
||
c.x = c.x + (c.w - newW) * cx;
|
||
c.y = c.y + (c.h - newH) * cy;
|
||
c.w = newW;
|
||
c.h = newH;
|
||
fitCrop(node, c);
|
||
writeCrop(node);
|
||
layoutVueOverlay(node);
|
||
}, { passive: false });
|
||
|
||
let ro = null;
|
||
try {
|
||
ro = new ResizeObserver(() => layoutVueOverlay(node));
|
||
ro.observe(parts.panel);
|
||
} catch (_) { /* ResizeObserver optional */ }
|
||
|
||
st.vue = { ov, box, label, handles, panel: parts.panel, img: parts.img, ro };
|
||
layoutVueOverlay(node);
|
||
}
|
||
|
||
/**
|
||
* 2.0 has no canvas redraw we can hook into, so a light timer keeps the
|
||
* overlays in sync with the DOM (image load, ratio change, Vue re-render).
|
||
*/
|
||
let vuePollerStarted = false;
|
||
|
||
function startVuePoller(app) {
|
||
if (vuePollerStarted) return;
|
||
vuePollerStarted = true;
|
||
setInterval(() => {
|
||
try {
|
||
if (!app || !app.graph) return;
|
||
const vue = isVueMode();
|
||
for (const n of app.graph.nodes || []) {
|
||
if (!n || n.type !== LIRC.NODE_NAME || !n.__lirc || !n.__lirc.inited)
|
||
continue;
|
||
if (vue) {
|
||
layoutVueOverlay(n);
|
||
} else if (n.__lirc.vue) {
|
||
// the 2.0 setting was switched off at runtime: drop the DOM
|
||
// overlay so the classic canvas path takes over again
|
||
removeVueOverlay(n);
|
||
}
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
}, 250);
|
||
}
|
||
|
||
/* --------------------------- wheel zoom ------------------------------- */
|
||
|
||
let wheelInstalled = false;
|
||
|
||
function installWheelInterceptor(app) {
|
||
if (wheelInstalled) return;
|
||
wheelInstalled = true;
|
||
document.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
try {
|
||
if (!app || !app.graph || !app.canvas) return;
|
||
// find a LoadImageCrop node whose preview contains the pointer
|
||
const pos = app.clientPosToCanvasPos
|
||
? app.clientPosToCanvasPos([e.clientX, e.clientY])
|
||
: null;
|
||
if (!pos) return;
|
||
const nodes = app.graph.nodes || [];
|
||
for (const n of nodes) {
|
||
if (n.type !== LIRC.NODE_NAME || !n.__lirc) continue;
|
||
const st = n.__lirc;
|
||
// 2.0: the wheel may never reach the DOM overlay (the Vue layer
|
||
// forwards it to the canvas), so the zoom is handled here, at
|
||
// document capture time, before anything else sees the event.
|
||
if (isVueMode()) {
|
||
if (!st.vue || !st.vue.ov) continue;
|
||
const boxEl = st.vue.box;
|
||
const imgEl = st.vue.img;
|
||
if (!boxEl || !imgEl) continue;
|
||
const br = boxEl.getBoundingClientRect();
|
||
const overBox =
|
||
e.clientX >= br.left &&
|
||
e.clientX <= br.right &&
|
||
e.clientY >= br.top &&
|
||
e.clientY <= br.bottom;
|
||
if (!overBox) continue; // outside the crop: normal canvas behavior
|
||
if (st.crop) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// same object-contain mapping as the overlay layout (the <img>
|
||
// element rect can drift from the contained image area)
|
||
const pr = (st.vue.panel || imgEl.parentElement).getBoundingClientRect();
|
||
const nat = { w: imgEl.naturalWidth, h: imgEl.naturalHeight };
|
||
const r = ratioOf(n);
|
||
if (r == null) return;
|
||
// Free: zoom keeps the box's own (drawn) ratio
|
||
const rr = r === "free" ? (st.crop.w * nat.w) / (st.crop.h * nat.h) : r;
|
||
const fr = maxFitRatio(rr, nat.w, nat.h);
|
||
const c = st.crop;
|
||
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||
let newH = c.h * factor;
|
||
if (newH > fr.h) newH = fr.h;
|
||
if (newH < 0.02) newH = 0.02;
|
||
if (Math.abs(newH - c.h) > 1e-6) {
|
||
const k = rr * (nat.h / nat.w);
|
||
const newW = newH * k;
|
||
const zs = Math.min((pr.width || 1) / nat.w, (pr.height || 1) / nat.h);
|
||
const dw = nat.w * zs, dh = nat.h * zs;
|
||
const ox = (pr.width - dw) / 2, oy = (pr.height - dh) / 2;
|
||
const nx = (e.clientX - pr.left - ox) / (dw || 1);
|
||
const ny = (e.clientY - pr.top - oy) / (dh || 1);
|
||
c.x = c.x + (c.w - newW) * (nx - c.x);
|
||
c.y = c.y + (c.h - newH) * (ny - c.y);
|
||
c.w = newW;
|
||
c.h = newH;
|
||
fitCrop(n, c);
|
||
writeCrop(n);
|
||
layoutVueOverlay(n);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const local = [pos[0] - n.pos[0], pos[1] - n.pos[1]];
|
||
if (st.rect && pointInRect(local, st.rect) && !st.crop) {
|
||
// "Original": no crop to zoom, keep the core canvas behavior
|
||
return;
|
||
}
|
||
if (st.rect && pointInRect(local, st.rect) && st.crop) {
|
||
// always consume the wheel over the preview (no canvas zoom/pan)
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const img = naturalSize(n);
|
||
if (img) {
|
||
const c = st.crop;
|
||
const rm = ratioOf(n);
|
||
// Free: zoom keeps the box's own (drawn) ratio
|
||
const rr = rm === "free" ? (c.w * img.w) / (c.h * img.h) : rm;
|
||
const fr = maxFitRatio(rr, img.w, img.h);
|
||
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||
// clamp the TARGET size first: at a limit the size stays the
|
||
// same, so the rectangle must not move
|
||
let newH = c.h * factor;
|
||
if (newH > fr.h) newH = fr.h;
|
||
if (newH < 0.02) newH = 0.02;
|
||
if (Math.abs(newH - c.h) > 1e-6) {
|
||
const k = rr * (img.h / img.w);
|
||
const newW = newH * k;
|
||
const r = st.rect;
|
||
// zoom around the cursor
|
||
const cx = (local[0] - r.x) / r.w;
|
||
const cy = (local[1] - r.y) / r.h;
|
||
c.x = c.x + (c.w - newW) * cx;
|
||
c.y = c.y + (c.h - newH) * cy;
|
||
c.w = newW;
|
||
c.h = newH;
|
||
fitCrop(n, c);
|
||
writeCrop(n);
|
||
markDirty(n);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn(LIRC.TAG, "wheel", err);
|
||
}
|
||
},
|
||
{ passive: false, capture: true }
|
||
);
|
||
}
|
||
|
||
/* --------------------------- paste ------------------------------------ */
|
||
|
||
/**
|
||
* Context-menu paste for classic nodes: the frontend already handles
|
||
* Ctrl+V natively in both designs, but classic exposes no menu entry for
|
||
* it. This mirrors the core "Paste Image" entry: read the clipboard and
|
||
* hand the image to the node's native paste methods (node.pasteFile /
|
||
* node.pasteFiles), the same path the core uses. Like the core entry it
|
||
* only covers clipboard images (screenshots); OS file copies work via
|
||
* Ctrl+V.
|
||
*/
|
||
async function pasteFromClipboard(node) {
|
||
if (!navigator.clipboard || !navigator.clipboard.read) {
|
||
alert("Clipboard access is not available in this browser. Use Chrome/Edge over http://localhost and allow clipboard access.");
|
||
return;
|
||
}
|
||
try {
|
||
const items = await navigator.clipboard.read();
|
||
for (const item of items) {
|
||
const type = item.types.find((t) => t.startsWith("image/"));
|
||
if (type) {
|
||
const blob = await item.getType(type);
|
||
const ext = (type.split("/")[1] || "png").split(";")[0];
|
||
const file = new File([blob], `pasted-image.${ext}`, { type });
|
||
if (typeof node.pasteFile === "function") node.pasteFile(file);
|
||
if (typeof node.pasteFiles === "function") node.pasteFiles([file]);
|
||
return;
|
||
}
|
||
}
|
||
alert("No image found in the clipboard.");
|
||
} catch (e) {
|
||
console.error(LIRC.TAG, "paste failed", e);
|
||
alert("Paste from clipboard failed: " + (e && e.message ? e.message : e));
|
||
}
|
||
}
|
||
|
||
/* --------------------------- registration ----------------------------- */
|
||
|
||
function register(app) {
|
||
lircApp = app;
|
||
app.registerExtension({
|
||
name: "LoadImageCrop",
|
||
nodeCreated: (node) => initNode(node),
|
||
loadedGraphNode: (node) => initNode(node),
|
||
beforeRegisterNodeDef: (nodeType, nodeData) => {
|
||
if (nodeData && nodeData.name !== LIRC.NODE_NAME) return;
|
||
const proto = nodeType.prototype;
|
||
const prevConfigure = proto.onConfigure;
|
||
proto.onConfigure = function (data) {
|
||
const r = prevConfigure ? prevConfigure.call(this, data) : undefined;
|
||
initNode(this);
|
||
return r;
|
||
};
|
||
const prevAdded = proto.onAdded;
|
||
proto.onAdded = function (graph) {
|
||
const r = prevAdded ? prevAdded.call(this, graph) : undefined;
|
||
initNode(this);
|
||
return r;
|
||
};
|
||
}
|
||
});
|
||
installWheelInterceptor(app);
|
||
startVuePoller(app);
|
||
console.log(LIRC.TAG, "extension registered");
|
||
}
|
||
|
||
(async function boot() {
|
||
let mod = null;
|
||
for (let i = 0; i < 100; i++) {
|
||
try {
|
||
mod = await import("/scripts/app.js");
|
||
if (mod && mod.app) break;
|
||
mod = null;
|
||
} catch (_) { /* retry */ }
|
||
await sleep(50);
|
||
}
|
||
if (!mod || !mod.app) {
|
||
console.error(LIRC.TAG, "could not load /scripts/app.js - extension not started");
|
||
return;
|
||
}
|
||
try {
|
||
register(mod.app);
|
||
} catch (e) {
|
||
console.error(LIRC.TAG, "register failed", e);
|
||
}
|
||
})();
|