Files
domg73 e490a44ef2 v1.0.7: pre-crop flip (Horizontal/Vertical)
- 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
2026-09-29 11:40:13 +02:00

1335 lines
50 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.
/*
* 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);
}
})();