2059 lines
94 KiB
JavaScript
2059 lines
94 KiB
JavaScript
import { chainCallback, addMiddleClickPan, addWheelPassthrough, captureVideoFrame, watchImageInputs, rectHitTest, cursorForBboxMode } from './utility.js';
|
||
const { app } = window.comfyAPI.app;
|
||
|
||
const BBOX_PALETTE = ["#46b4e6", "#e68246", "#82e646", "#e646b4", "#e6e646", "#46e6c8"];
|
||
const MAX_CANVAS_W = 1024, MAX_CANVAS_H = 768;
|
||
const BBOX_COLORS = BBOX_PALETTE.map((hex) => {
|
||
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
|
||
return { rgb: `rgb(${r}, ${g}, ${b})`, gridActive: `rgba(${r}, ${g}, ${b}, 0.35)`,
|
||
gridInactive: `rgba(${r}, ${g}, ${b}, 0.3)`, tint: `rgba(${r}, ${g}, ${b}, 0.2)` };
|
||
});
|
||
|
||
function getBboxColor(index, active) {
|
||
const c = BBOX_COLORS[index % BBOX_COLORS.length];
|
||
return { border: c.rgb, fill: c.rgb, handle: c.rgb, gridColor: active ? c.gridActive : c.gridInactive, tint: c.tint };
|
||
}
|
||
|
||
function clampToMaxCanvas(w, h) {
|
||
if (w > MAX_CANVAS_W || h > MAX_CANVAS_H) {
|
||
const s = Math.min(MAX_CANVAS_W / w, MAX_CANVAS_H / h);
|
||
return [Math.round(w * s), Math.round(h * s)];
|
||
}
|
||
return [w, h];
|
||
}
|
||
|
||
function roundDown(val, divBy) {
|
||
return divBy > 1 ? val - (val % divBy) : val;
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "KJNodes.ImageTransformKJ",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData?.name !== "ImageTransformKJ") return;
|
||
|
||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||
const node = this;
|
||
|
||
const findW = (n) => this.widgets.find((w) => w.name === n);
|
||
const bboxWidget = findW("bboxes"), twWidget = findW("target_width"), thWidget = findW("target_height");
|
||
const kpWidget = findW("keep_proportion"), divWidget = findW("divisible_by");
|
||
const epWidget = findW("extra_padding"), icWidget = findW("invert_crop");
|
||
bboxWidget.hidden = true;
|
||
|
||
const _subWidgetCache = {};
|
||
function _refreshSubWidgetCache() {
|
||
if (!node.widgets) return;
|
||
for (const w of node.widgets) {
|
||
if (w.name?.includes(".")) _subWidgetCache[w.name] = w;
|
||
}
|
||
}
|
||
function _getSubWidget(name) {
|
||
let w = _subWidgetCache[name];
|
||
if (w && node.widgets?.includes(w)) return w;
|
||
// Cache miss — rescan
|
||
w = node.widgets?.find(ww => ww.name === name);
|
||
if (w) _subWidgetCache[name] = w;
|
||
return w || null;
|
||
}
|
||
|
||
const _tmpCanvas = document.createElement("canvas"), _tmpCtx = _tmpCanvas.getContext("2d");
|
||
const _mirrorCanvas = document.createElement("canvas"), _mirrorCtx = _mirrorCanvas.getContext("2d");
|
||
const _rotCanvas = document.createElement("canvas"), _rotCtx = _rotCanvas.getContext("2d");
|
||
let _rotCacheKey = "";
|
||
function getRotatedPreview() {
|
||
if (!node._previewImg) return null;
|
||
const nw = node._previewImg.naturalWidth;
|
||
const nh = node._previewImg.naturalHeight;
|
||
const { w: effW, h: effH } = getEffectiveImageDims(true);
|
||
// Scale to reasonable preview size
|
||
let [rw, rh] = clampToMaxCanvas(effW, effH);
|
||
const key = `${node._previewImg.src}|${node._rotation}|${rw}|${rh}`;
|
||
if (_rotCacheKey === key) return _rotCanvas;
|
||
_rotCanvas.width = rw;
|
||
_rotCanvas.height = rh;
|
||
_rotCtx.clearRect(0, 0, rw, rh);
|
||
if (node._rotation === 0) {
|
||
_rotCtx.drawImage(node._previewImg, 0, 0, rw, rh);
|
||
} else {
|
||
const rad = Math.abs(rotRad());
|
||
const rotW = Math.abs(nw * Math.cos(rad)) + Math.abs(nh * Math.sin(rad));
|
||
const rotH = Math.abs(nw * Math.sin(rad)) + Math.abs(nh * Math.cos(rad));
|
||
const fitScale = Math.min(rw / rotW, rh / rotH);
|
||
_rotCtx.translate(rw / 2, rh / 2);
|
||
_rotCtx.rotate(rotRad());
|
||
_rotCtx.drawImage(node._previewImg, -(nw * fitScale) / 2, -(nh * fitScale) / 2, nw * fitScale, nh * fitScale);
|
||
_rotCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
}
|
||
_rotCacheKey = key;
|
||
return _rotCanvas;
|
||
}
|
||
|
||
let _frameEp = { top: 0, bottom: 0, left: 0, right: 0, mode: "disabled", color: null, edgeMode: "clamp" };
|
||
let _framePadXY = { x: 0.5, y: 0.5 };
|
||
|
||
const wrapper = document.createElement("div");
|
||
wrapper.style.cssText = "display:flex;flex-direction:column;overflow:hidden;position:relative;pointer-events:auto;";
|
||
const canvasEl = document.createElement("canvas");
|
||
const canvasCtx = canvasEl.getContext("2d");
|
||
canvasEl.style.cssText = "cursor:crosshair;display:block;width:100%;height:auto;";
|
||
addWheelPassthrough(wrapper);
|
||
addMiddleClickPan(canvasEl);
|
||
|
||
// Inject slider thumb styles once
|
||
if (!document.getElementById("kjcrop-slider-style")) {
|
||
const style = document.createElement("style");
|
||
style.id = "kjcrop-slider-style";
|
||
style.textContent = `
|
||
.kjcrop-slider { -webkit-appearance: none; appearance: none; background: #444; border-radius: 4px; outline: none; }
|
||
.kjcrop-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 8px; height: 18px; background: #46b4e6; border-radius: 4px; cursor: pointer; border: none; }
|
||
.kjcrop-slider::-moz-range-thumb { width: 8px; height: 18px; background: #46b4e6; border-radius: 4px; cursor: pointer; border: none; }
|
||
.kjcrop-btn { background: #333; border: 1px solid #555; border-radius: 4px; color: #bbb; font: 11px sans-serif; cursor: pointer; padding: 2px 8px; line-height: 16px; white-space: nowrap; flex-shrink: 0; transition: border-color 0.15s, color 0.15s, background 0.15s; text-align: center; }
|
||
.kjcrop-btn:hover { border-color: #46b4e6; color: #fff; background: #3a3a3a; }
|
||
.kjcrop-btn.active { border-color: #46b4e6; color: #46b4e6; background: #2a3a42; }
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
// Grid size slider overlay (purely visual aid, not serialized)
|
||
const gridBar = document.createElement("div");
|
||
gridBar.style.cssText = "display:flex;align-items:center;gap:6px;padding:3px 6px;margin-bottom:4px;font:11px sans-serif;color:#aaa;user-select:none;box-sizing:border-box;width:100%;flex:0 0 auto;";
|
||
const gridLabel = document.createElement("span");
|
||
gridLabel.textContent = "Grid: off";
|
||
gridLabel.style.cssText = "min-width:62px;text-align:right;";
|
||
const gridSlider = document.createElement("input");
|
||
gridSlider.type = "range";
|
||
gridSlider.min = "0";
|
||
gridSlider.max = "256";
|
||
gridSlider.step = "1";
|
||
gridSlider.value = node.properties.gridSize ?? "0";
|
||
gridSlider.className = "kjcrop-slider";
|
||
gridSlider.style.cssText = "flex:1;height:8px;cursor:pointer;";
|
||
function updateGridLabel() {
|
||
const v = parseInt(gridSlider.value);
|
||
gridLabel.textContent = v > 0 ? `Grid: ${v}px` : "Grid: off";
|
||
}
|
||
updateGridLabel();
|
||
let _gridStops = null;
|
||
function getGridStops() {
|
||
if (!node._previewImg) return null;
|
||
const tw = node._previewImg.naturalWidth;
|
||
const th = node._previewImg.naturalHeight;
|
||
const max = parseInt(gridSlider.max);
|
||
const set = new Set([0]);
|
||
for (let n = 1; n <= Math.max(tw, th); n++) {
|
||
const gw = tw / n, gh = th / n;
|
||
if (gw >= 2 && gw <= max) set.add(Math.round(gw * 10) / 10);
|
||
if (gh >= 2 && gh <= max) set.add(Math.round(gh * 10) / 10);
|
||
if (gw < 2 && gh < 2) break;
|
||
}
|
||
return [...set].sort((a, b) => a - b);
|
||
}
|
||
function invalidateGridStops() { _gridStops = null; }
|
||
gridSlider.addEventListener("input", () => {
|
||
let v = parseInt(gridSlider.value);
|
||
// Snap to divisors of the longer dimension for whole-cell grid
|
||
if (v > 0 && node._previewImg) {
|
||
if (!_gridStops) _gridStops = getGridStops();
|
||
if (_gridStops) {
|
||
let best = 0, bestDist = Infinity;
|
||
for (const s of _gridStops) {
|
||
const d = Math.abs(s - v);
|
||
if (d < bestDist) { bestDist = d; best = s; }
|
||
}
|
||
v = best;
|
||
}
|
||
}
|
||
gridSlider.value = Math.round(v);
|
||
node.properties.gridSize = Math.round(v);
|
||
updateGridLabel();
|
||
drawCanvas();
|
||
});
|
||
// Prevent drag events from propagating to the LiteGraph canvas,
|
||
// but only when not actively dragging a bbox handle
|
||
for (const evt of ["mousedown", "mousemove", "mouseup"]) {
|
||
gridSlider.addEventListener(evt, (e) => {
|
||
if (!node._drawing) e.stopPropagation();
|
||
});
|
||
}
|
||
// Toggle button to disable image preview (draw bboxes on blank canvas)
|
||
const previewBtn = document.createElement("button");
|
||
previewBtn.title = "Auto: canvas sized to input image. Manual: canvas sized to target dimensions.";
|
||
previewBtn.className = "kjcrop-btn";
|
||
previewBtn.style.width = "56px";
|
||
if (node.properties.previewEnabled === undefined) node.properties.previewEnabled = true;
|
||
node._previewEnabled = node.properties.previewEnabled;
|
||
|
||
function updatePreviewBtn() {
|
||
previewBtn.textContent = node._previewEnabled ? "Auto" : "Manual";
|
||
previewBtn.classList.toggle("active", !node._previewEnabled);
|
||
}
|
||
updatePreviewBtn();
|
||
|
||
previewBtn.addEventListener("click", () => {
|
||
node._previewEnabled = !node._previewEnabled;
|
||
node.properties.previewEnabled = node._previewEnabled;
|
||
updatePreviewBtn();
|
||
if (!node._previewEnabled) {
|
||
updateCanvasFromTargetDims();
|
||
} else if (node._previewImg) {
|
||
// Restore canvas to image dimensions
|
||
const [cw, ch] = clampToMaxCanvas(node._previewImg.naturalWidth, node._previewImg.naturalHeight);
|
||
setCanvasSize(cw, ch);
|
||
}
|
||
drawCanvas();
|
||
});
|
||
previewBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
|
||
const colorSwatch = document.createElement("div");
|
||
const colorInput = document.createElement("input");
|
||
colorInput.type = "color";
|
||
colorInput.value = node.properties.fillColor || "#000000";
|
||
colorInput.style.cssText = "position:absolute;opacity:0;width:0;height:0;pointer-events:none;";
|
||
colorSwatch.style.cssText = "width:18px;height:18px;border:1px solid #666;border-radius:3px;cursor:pointer;flex-shrink:0;background:" + (node.properties.fillColor || "#000000") + ";";
|
||
colorSwatch.title = "Fill color (used by pad, invert crop, rotation)";
|
||
colorSwatch.appendChild(colorInput);
|
||
colorSwatch.addEventListener("click", () => colorInput.click());
|
||
colorSwatch.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
colorInput.addEventListener("input", () => {
|
||
node.properties.fillColor = colorInput.value; colorSwatch.style.background = colorInput.value;
|
||
updateBboxWidgets(); drawCanvas();
|
||
});
|
||
|
||
function getEffectiveImageDims(expand = true) {
|
||
const w = node._previewImg.naturalWidth, h = node._previewImg.naturalHeight;
|
||
if (node._rotation === 0 || !expand) return { w, h };
|
||
const rad = Math.abs(rotRad());
|
||
const cosR = Math.cos(rad), sinR = Math.sin(rad);
|
||
return { w: Math.round(Math.abs(w * cosR) + Math.abs(h * sinR)),
|
||
h: Math.round(Math.abs(w * sinR) + Math.abs(h * cosR)) };
|
||
}
|
||
|
||
function renderReplicateRotated(destW, destH) {
|
||
const img = node._previewImg, nw = img.naturalWidth, nh = img.naturalHeight;
|
||
const pad = Math.max(nw, nh), pw = nw + pad * 2, ph = nh + pad * 2;
|
||
// Build replicate-padded image on _mirrorCanvas
|
||
_mirrorCanvas.width = pw; _mirrorCanvas.height = ph;
|
||
_mirrorCtx.drawImage(img, pad, pad, nw, nh);
|
||
// Edges (top, bottom, left, right)
|
||
_mirrorCtx.drawImage(img, 0, 0, nw, 1, pad, 0, nw, pad);
|
||
_mirrorCtx.drawImage(img, 0, nh - 1, nw, 1, pad, pad + nh, nw, pad);
|
||
_mirrorCtx.drawImage(img, 0, 0, 1, nh, 0, pad, pad, nh);
|
||
_mirrorCtx.drawImage(img, nw - 1, 0, 1, nh, pad + nw, pad, pad, nh);
|
||
// Corners (TL, TR, BL, BR)
|
||
_mirrorCtx.drawImage(img, 0, 0, 1, 1, 0, 0, pad, pad);
|
||
_mirrorCtx.drawImage(img, nw - 1, 0, 1, 1, pad + nw, 0, pad, pad);
|
||
_mirrorCtx.drawImage(img, 0, nh - 1, 1, 1, 0, pad + nh, pad, pad);
|
||
_mirrorCtx.drawImage(img, nw - 1, nh - 1, 1, 1, pad + nw, pad + nh, pad, pad);
|
||
// Rotate padded image, crop to original size, scale to dest
|
||
_tmpCanvas.width = destW; _tmpCanvas.height = destH;
|
||
_tmpCtx.save();
|
||
_tmpCtx.scale(destW / nw, destH / nh);
|
||
_tmpCtx.translate(nw / 2, nh / 2);
|
||
_tmpCtx.rotate(rotRad());
|
||
_tmpCtx.drawImage(_mirrorCanvas, -(pw / 2), -(ph / 2));
|
||
_tmpCtx.restore();
|
||
}
|
||
|
||
// Resize node to fit the current canvas/image. If onlyGrow is true,
|
||
// the node will only get larger, never shrink (preserves user resize).
|
||
// If false, the node is set to the exact size needed.
|
||
function fitNodeToImage(onlyGrow) {
|
||
if (!node._previewEnabled) {
|
||
// Manual mode: only grow to fit current canvas, never change width
|
||
if (onlyGrow && canvasEl.width > 0 && canvasEl.height > 0) {
|
||
_resizing = true;
|
||
const displayedH = Math.round(canvasEl.height * ((node.size[0] - 30) / canvasEl.width));
|
||
node._widgetHeight = displayedH + GRID_BAR_HEIGHT;
|
||
const aboveH = node.cropEditor?.last_y || 350;
|
||
const neededH = aboveH + node._widgetHeight + 20;
|
||
if (node.size[1] < neededH) node.setSize([node.size[0], neededH]);
|
||
_resizing = false;
|
||
}
|
||
return;
|
||
}
|
||
if (!node._previewImg) return;
|
||
_resizing = true;
|
||
const { w: effW, h: effH } = getEffectiveImageDims(true);
|
||
const ar = effH / effW;
|
||
const nodeW = onlyGrow ? node.size[0] : canvasEl.width + 30;
|
||
const canvasDisplayH = Math.round((nodeW - 30) * ar);
|
||
node._widgetHeight = canvasDisplayH + GRID_BAR_HEIGHT;
|
||
const aboveH = node.cropEditor?.last_y || 350;
|
||
// +20 accounts for DOM widget margin (10px top + 10px bottom)
|
||
const neededH = aboveH + node._widgetHeight + 20;
|
||
const newW = onlyGrow ? Math.max(node.size[0], nodeW) : nodeW;
|
||
const newH = onlyGrow ? Math.max(node.size[1], neededH) : neededH;
|
||
if (newW !== node.size[0] || newH !== node.size[1]) {
|
||
node.setSize([newW, newH]);
|
||
}
|
||
_resizing = false;
|
||
// Force reflow and redraw so the layout updates immediately
|
||
void canvasEl.offsetHeight;
|
||
if (node.graph) {
|
||
node.graph.setDirtyCanvas(true, true);
|
||
requestAnimationFrame(() => {
|
||
drawCanvas();
|
||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||
});
|
||
}
|
||
}
|
||
|
||
function resetRotation() {
|
||
if (node._rotation === 0) return;
|
||
node._rotation = 0;
|
||
node.properties.rotation = 0;
|
||
restoreCanvasToImage();
|
||
fitNodeToImage(false);
|
||
updateBboxWidgets();
|
||
drawCanvas();
|
||
}
|
||
|
||
const rotateBtn = document.createElement("button");
|
||
rotateBtn.title = "Toggle rotation cross (drag to rotate image)";
|
||
rotateBtn.className = "kjcrop-btn";
|
||
if (node.properties.showRotationCross === undefined) node.properties.showRotationCross = false;
|
||
node._showRotationCross = node.properties.showRotationCross;
|
||
|
||
function updateRotateBtn() {
|
||
rotateBtn.textContent = "Rotate";
|
||
rotateBtn.classList.toggle("active", node._showRotationCross);
|
||
}
|
||
updateRotateBtn();
|
||
|
||
rotateBtn.addEventListener("click", () => {
|
||
node._showRotationCross = !node._showRotationCross;
|
||
node.properties.showRotationCross = node._showRotationCross;
|
||
updateRotateBtn();
|
||
drawCanvas();
|
||
});
|
||
rotateBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
rotateBtn.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
resetRotation();
|
||
});
|
||
|
||
const gridColorInput = document.createElement("input");
|
||
gridColorInput.type = "color";
|
||
gridColorInput.value = node.properties.gridColor || "#ffffff";
|
||
gridColorInput.style.cssText = "position:absolute;opacity:0;width:0;height:0;pointer-events:none;";
|
||
const gridColorSwatch = document.createElement("div");
|
||
gridColorSwatch.style.cssText = "width:18px;height:18px;border:1px solid #666;border-radius:3px;cursor:pointer;flex-shrink:0;background:" + (node.properties.gridColor || "#ffffff") + ";";
|
||
gridColorSwatch.title = "Grid color";
|
||
gridColorSwatch.appendChild(gridColorInput);
|
||
gridColorSwatch.addEventListener("click", () => gridColorInput.click());
|
||
gridColorSwatch.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
let _gridColorRGB = null;
|
||
function getGridColorRGB() {
|
||
const gc = node.properties.gridColor || "#ffffff";
|
||
if (!_gridColorRGB || _gridColorRGB.hex !== gc) {
|
||
_gridColorRGB = { hex: gc, r: parseInt(gc.slice(1, 3), 16), g: parseInt(gc.slice(3, 5), 16), b: parseInt(gc.slice(5, 7), 16) };
|
||
}
|
||
return _gridColorRGB;
|
||
}
|
||
gridColorInput.addEventListener("input", () => {
|
||
node.properties.gridColor = gridColorInput.value; gridColorSwatch.style.background = gridColorInput.value;
|
||
_gridColorRGB = null;
|
||
drawCanvas();
|
||
});
|
||
|
||
for (const el of [previewBtn, rotateBtn, colorSwatch, gridLabel, gridColorSwatch, gridSlider]) gridBar.appendChild(el);
|
||
wrapper.appendChild(gridBar); wrapper.appendChild(canvasEl);
|
||
|
||
const GRID_BAR_HEIGHT = 46;
|
||
node._widgetHeight = 300 + GRID_BAR_HEIGHT;
|
||
|
||
node.cropEditor = this.addDOMWidget("crop_preview", "CropPreviewWidget", wrapper, {
|
||
serialize: false, hideOnZoom: false,
|
||
getMinHeight: () => GRID_BAR_HEIGHT + 50,
|
||
});
|
||
|
||
this.resizable = true;
|
||
// Set default width wider than LiteGraph's default — deferred so onConfigure can set the flag first
|
||
setTimeout(() => {
|
||
if (!node._cropConfigured) node.setSize([450, node.size[1]]);
|
||
}, 0);
|
||
|
||
Object.assign(node, { _previewImg: null, _bboxes: [], _activeIdx: -1,
|
||
_drawing: false, _dragMode: null, _dragStart: null, _bboxAtDragStart: null,
|
||
_rotation: node.properties.rotation || 0 });
|
||
|
||
for (const w of [twWidget, thWidget, kpWidget, divWidget, epWidget, icWidget]) {
|
||
if (!w) continue;
|
||
const origCb = w.callback;
|
||
w.callback = function (...args) {
|
||
if (origCb) origCb.apply(this, args);
|
||
updateCanvasFromTargetDims();
|
||
drawCanvas();
|
||
fitNodeToCanvas();
|
||
fitNodeToImage(true);
|
||
if (w === kpWidget || w === icWidget || w === epWidget) {
|
||
// Restore values with increasing delays to catch ComfyUI's own default-setting
|
||
const restoreSaved = () => {
|
||
hookSubWidgets();
|
||
_refreshSubWidgetCache();
|
||
if (w === epWidget && node._savedEpValues && epWidget.value !== "disabled") {
|
||
for (const [n, v] of Object.entries(node._savedEpValues)) {
|
||
const sw = _getSubWidget(`extra_padding.${n}`);
|
||
if (sw) sw.value = v;
|
||
}
|
||
} else if (w === kpWidget && node._savedKpValues) {
|
||
for (const [n, v] of Object.entries(node._savedKpValues)) {
|
||
const sw = _getSubWidget(`keep_proportion.${n}`);
|
||
if (sw) sw.value = v;
|
||
}
|
||
}
|
||
drawCanvas();
|
||
fitNodeToCanvas();
|
||
fitNodeToImage(true);
|
||
};
|
||
for (const delay of [50, 100, 200, 300, 500, 1000]) {
|
||
setTimeout(restoreSaved, delay);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
const hookSubWidgets = () => {
|
||
_refreshSubWidgetCache();
|
||
for (const subName of ["keep_proportion.edge_mode", "keep_proportion.pad_x", "keep_proportion.pad_y", "keep_proportion.width_mult", "keep_proportion.height_mult", "extra_padding.pad_top", "extra_padding.pad_bottom", "extra_padding.pad_left", "extra_padding.pad_right", "extra_padding.edge_mode", ]) {
|
||
const sw = _getSubWidget(subName);
|
||
if (sw && !sw._cropPreviewHooked) {
|
||
sw._cropPreviewHooked = true;
|
||
// Restore saved values from mode switch
|
||
const shortName = subName.split(".")[1];
|
||
if (subName.startsWith("extra_padding.") && node._savedEpValues && shortName in node._savedEpValues) {
|
||
sw.value = node._savedEpValues[shortName];
|
||
} else if (subName.startsWith("keep_proportion.") && node._savedKpValues && shortName in node._savedKpValues) {
|
||
sw.value = node._savedKpValues[shortName];
|
||
}
|
||
// Hook callback — save values on change so they persist across mode switches
|
||
const swOrig = sw.callback;
|
||
sw.callback = function (...a) {
|
||
if (_suppressSubCallbacks) return;
|
||
if (subName.startsWith("extra_padding.")) node._savedEpValues[shortName] = sw.value;
|
||
else if (subName.startsWith("keep_proportion.")) node._savedKpValues[shortName] = sw.value;
|
||
if (swOrig) swOrig.apply(this, a);
|
||
drawCanvas();
|
||
};
|
||
}
|
||
}
|
||
};
|
||
for (const delay of [50, 150, 300, 500]) {
|
||
setTimeout(hookSubWidgets, delay);
|
||
}
|
||
|
||
let _lastWidgetSnapshot = "";
|
||
const _snapshotNames = [
|
||
"extra_padding", "extra_padding.pad_top", "extra_padding.pad_bottom",
|
||
"extra_padding.pad_left", "extra_padding.pad_right",
|
||
"extra_padding.edge_mode",
|
||
"keep_proportion.pad_x", "keep_proportion.pad_y", "keep_proportion.color",
|
||
"keep_proportion.edge_mode", "keep_proportion.width_mult", "keep_proportion.height_mult",
|
||
];
|
||
node._savedEpValues = node._savedEpValues || {};
|
||
node._savedKpValues = node._savedKpValues || {};
|
||
const origDrawFg = node.onDrawForeground;
|
||
node.onDrawForeground = function (...args) {
|
||
if (origDrawFg) origDrawFg.apply(this, args);
|
||
const snap = _snapshotNames.map(n => _getSubWidget(n)?.value ?? (n === "extra_padding" ? epWidget?.value : "") ?? "").join("|");
|
||
if (snap !== _lastWidgetSnapshot) {
|
||
_lastWidgetSnapshot = snap;
|
||
drawCanvas();
|
||
}
|
||
};
|
||
|
||
// Restore bboxes from widget
|
||
restoreBboxesFromWidget();
|
||
|
||
// Boxes are serialized in the canvas frame they were authored in (previewWidth/Height).
|
||
// Bring them into the current canvas frame so the in-memory coords stay canvas-relative.
|
||
function normalizeLoadedBboxes(boxes) {
|
||
const cw = canvasEl.width, ch = canvasEl.height;
|
||
if (!cw || !ch) return boxes;
|
||
return boxes.map((b) => {
|
||
const pw = b.previewWidth, ph = b.previewHeight;
|
||
if (!pw || !ph || (pw === cw && ph === ch)) {
|
||
const { previewWidth, previewHeight, ...rest } = b;
|
||
return rest;
|
||
}
|
||
const sx = cw / pw, sy = ch / ph;
|
||
return { startX: b.startX * sx, startY: b.startY * sy, endX: b.endX * sx, endY: b.endY * sy };
|
||
});
|
||
}
|
||
|
||
function restoreBboxesFromWidget() {
|
||
if (bboxWidget.value) {
|
||
try {
|
||
const parsed = JSON.parse(bboxWidget.value);
|
||
// New format: { bboxes: [...], rotation: N }
|
||
if (parsed && !Array.isArray(parsed) && typeof parsed === "object") {
|
||
if (parsed.bboxes && Array.isArray(parsed.bboxes)) {
|
||
node._bboxes = normalizeLoadedBboxes(parsed.bboxes.filter((b) => b?.startX != null));
|
||
node._activeIdx = node._bboxes.length > 0 ? 0 : -1;
|
||
}
|
||
if (parsed.rotation !== undefined) {
|
||
node._rotation = parsed.rotation;
|
||
node.properties.rotation = parsed.rotation;
|
||
}
|
||
if (parsed.fillColor) {
|
||
node.properties.fillColor = parsed.fillColor;
|
||
colorInput.value = parsed.fillColor;
|
||
colorSwatch.style.background = parsed.fillColor;
|
||
}
|
||
}
|
||
// Legacy format: [bbox, bbox, ...]
|
||
else if (Array.isArray(parsed) && parsed.length > 0) {
|
||
node._bboxes = normalizeLoadedBboxes(parsed.filter((b) => b?.startX != null));
|
||
node._activeIdx = node._bboxes.length > 0 ? 0 : -1;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
function normBbox(bbox) {
|
||
return { x1: Math.min(bbox.startX, bbox.endX), y1: Math.min(bbox.startY, bbox.endY),
|
||
x2: Math.max(bbox.startX, bbox.endX), y2: Math.max(bbox.startY, bbox.endY) };
|
||
}
|
||
|
||
function getCanvasMouse(e) {
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
return { x: (e.clientX - rect.left) * (canvasEl.width / rect.width),
|
||
y: (e.clientY - rect.top) * (canvasEl.height / rect.height) };
|
||
}
|
||
|
||
function rotRad() { return node._rotation * Math.PI / 180; }
|
||
function getDivBy() { return divWidget ? divWidget.value : 0; }
|
||
function getGridSize() { return parseInt(gridSlider.value) || 0; }
|
||
// Subtract extra padding from target dims for non-pad keep_proportion modes
|
||
function adjustTargetForPadding(rw, rh, tgtW, tgtH) {
|
||
const ep = getExtraPadding();
|
||
if (hasExtraPad(ep) && !ep.isFirst) {
|
||
if (tgtW > 0) rw = Math.max(1, rw - ep.left - ep.right);
|
||
if (tgtH > 0) rh = Math.max(1, rh - ep.top - ep.bottom);
|
||
}
|
||
return { rw, rh };
|
||
}
|
||
function getEffectiveTargetDims(fallbackW, fallbackH) {
|
||
const divBy = getDivBy();
|
||
const { w: tgtW, h: tgtH } = getTgt();
|
||
let { rw, rh } = adjustTargetForPadding(tgtW > 0 ? tgtW : fallbackW, tgtH > 0 ? tgtH : fallbackH, tgtW, tgtH);
|
||
return { rw: roundDown(rw, divBy), rh: roundDown(rh, divBy) };
|
||
}
|
||
|
||
function getFillColor() { return node.properties.fillColor || "#000000"; }
|
||
function getTgt() { return { w: twWidget ? twWidget.value : 0, h: thWidget ? thWidget.value : 0 }; }
|
||
function getKp() { return kpWidget ? kpWidget.value : "stretch"; }
|
||
function isPadMode(kp) { return kp === "pad_color" || kp === "pad_edge"; }
|
||
function hasExtraPad(ep) { return ep.top + ep.bottom + ep.left + ep.right > 0; }
|
||
|
||
function hitTestRotationCross(mx, my) {
|
||
if (!node._showRotationCross) return false;
|
||
const cx = canvasEl.width / 2;
|
||
const cy = canvasEl.height / 2;
|
||
const dx = mx - cx;
|
||
const dy = my - cy;
|
||
const rad = rotRad();
|
||
const cosR = Math.cos(rad);
|
||
const sinR = Math.sin(rad);
|
||
const perpH = -dx * sinR + dy * cosR;
|
||
const perpV = dx * cosR + dy * sinR;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
return dist < 15 || Math.abs(perpH) < 8 || Math.abs(perpV) < 8;
|
||
}
|
||
|
||
function restoreCanvasToImage() {
|
||
if (!node._previewImg || !node._previewEnabled) return;
|
||
setCanvasSize(...clampToMaxCanvas(...Object.values(getEffectiveImageDims(true))));
|
||
}
|
||
|
||
function drawPreviewRegion(ctx, rx, ry, rw, rh, dx, dy, dw, dh) {
|
||
const rp = getRotatedPreview();
|
||
const sx = _rotCanvas.width / canvasEl.width, sy = _rotCanvas.height / canvasEl.height;
|
||
ctx.drawImage(rp, rx * sx, ry * sy, rw * sx, rh * sy, dx, dy, dw, dh);
|
||
}
|
||
|
||
function drawStretchOverlay(ctx, ar, rx, ry, rw, rh) {
|
||
const { pW, pH, pX, pY } = fitAspect(ar, rw, rh, rx, ry);
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||
ctx.fillRect(rx, ry, rw, rh);
|
||
if (node._previewEnabled && node._previewImg) drawPreviewRegion(ctx, rx, ry, rw, rh, pX, pY, pW, pH);
|
||
drawDashedBorder(ctx, pX, pY, pW, pH);
|
||
}
|
||
|
||
// Compute content dimensions after keep_proportion resize, before extra padding
|
||
function computeContentDims(cropW, cropH) {
|
||
if (cropW <= 0 || cropH <= 0) return null;
|
||
const { w: tgtW, h: tgtH } = getTgt();
|
||
let outW = cropW, outH = cropH;
|
||
const kp = getKp();
|
||
// For pad-first non-pad modes, the source is padded before keep_proportion
|
||
const ep = getExtraPadding();
|
||
if (ep.isFirst && hasExtraPad(ep) && !isPadMode(kp)) {
|
||
cropW += ep.left + ep.right;
|
||
cropH += ep.top + ep.bottom;
|
||
}
|
||
if (tgtW > 0 || tgtH > 0) {
|
||
let { rw, rh } = adjustTargetForPadding(tgtW > 0 ? tgtW : cropW, tgtH > 0 ? tgtH : cropH, tgtW, tgtH);
|
||
if (kp === "keep_long_edge") {
|
||
const ratio = Math.min(rw / cropW, rh / cropH);
|
||
rw = Math.round(cropW * ratio);
|
||
rh = Math.round(cropH * ratio);
|
||
} else if (kp === "keep_short_edge") {
|
||
const ratio = Math.max(rw / cropW, rh / cropH);
|
||
rw = Math.round(cropW * ratio);
|
||
rh = Math.round(cropH * ratio);
|
||
} else if (kp === "total_pixels") {
|
||
const totalPx = rw * rh;
|
||
const ar = cropW / cropH;
|
||
rh = Math.round(Math.sqrt(totalPx / ar));
|
||
rw = Math.round(Math.sqrt(totalPx * ar));
|
||
}
|
||
outW = rw;
|
||
outH = rh;
|
||
}
|
||
if (kp === "multiplier") {
|
||
const wm = _getSubWidget("keep_proportion.width_mult")?.value || 1.0;
|
||
const hm = _getSubWidget("keep_proportion.height_mult")?.value || 1.0;
|
||
outW = Math.round(cropW * wm);
|
||
outH = Math.round(cropH * hm);
|
||
}
|
||
return { w: outW, h: outH };
|
||
}
|
||
|
||
function computeOutputDims(cropW, cropH) {
|
||
const content = computeContentDims(cropW, cropH);
|
||
if (!content) return null;
|
||
let outW = content.w, outH = content.h;
|
||
// Add extra padding (skip for pad-first — handled on the source image)
|
||
const ep = getExtraPadding();
|
||
if (!ep.isFirst) {
|
||
outW += ep.left + ep.right;
|
||
outH += ep.top + ep.bottom;
|
||
}
|
||
const divBy = getDivBy();
|
||
outW = roundDown(outW, divBy);
|
||
outH = roundDown(outH, divBy);
|
||
return { w: outW, h: outH };
|
||
}
|
||
|
||
function getPadXY() { return _framePadXY; }
|
||
function getExtraPadding() { return _frameEp; }
|
||
|
||
function _readPadXY() {
|
||
const px = _getSubWidget("keep_proportion.pad_x"), py = _getSubWidget("keep_proportion.pad_y");
|
||
return { x: px ? px.value : 0.5, y: py ? py.value : 0.5 };
|
||
}
|
||
|
||
let _suppressSubCallbacks = false;
|
||
function setPadXY(x, y, defer) {
|
||
x = Math.max(0, Math.min(1, x));
|
||
y = Math.max(0, Math.min(1, y));
|
||
_framePadXY = { x, y };
|
||
if (!defer) {
|
||
const px = _getSubWidget("keep_proportion.pad_x"), py = _getSubWidget("keep_proportion.pad_y");
|
||
_suppressSubCallbacks = true;
|
||
if (px) px.value = x;
|
||
if (py) py.value = y;
|
||
_suppressSubCallbacks = false;
|
||
}
|
||
}
|
||
|
||
function _readExtraPadding() {
|
||
const get = (name) => _getSubWidget(`extra_padding.${name}`)?.value || 0;
|
||
const mode = epWidget?.value || "disabled";
|
||
// pad_color/pad_edge = pad first (before crop), pad_crop_color/pad_crop_edge = pad after crop
|
||
const isCrop = mode.startsWith("pad_crop_");
|
||
const baseMode = isCrop ? mode.replace("pad_crop_", "pad_") : mode;
|
||
return {
|
||
top: get("pad_top"), bottom: get("pad_bottom"),
|
||
left: get("pad_left"), right: get("pad_right"),
|
||
mode: baseMode,
|
||
isFirst: !isCrop && mode !== "disabled",
|
||
edgeMode: _getSubWidget("extra_padding.edge_mode")?.value || "clamp",
|
||
};
|
||
}
|
||
|
||
// Draw extra padding fill around content area (cX,cY,cW,cH) within pad area (pX,pY,pW,pH)
|
||
// srcImg is the source image, srcX/Y/W/H define the source region
|
||
// Draw edge/color fill around content area. srcImg must be pre-rendered at its full dimensions.
|
||
function drawExtraPadFill(ctx, ep, pX, pY, pW, pH, cX, cY, cW, cH, srcImg) {
|
||
if (ep.mode === "pad_color") {
|
||
ctx.fillStyle = getFillColor();
|
||
ctx.fillRect(pX, pY, pW, pH);
|
||
} else if (ep.mode === "pad_edge") {
|
||
const em = ep.edgeMode;
|
||
const sw = srcImg.width, sh = srcImg.height;
|
||
if (em === "clamp") {
|
||
const l = cX - pX, r = (pX + pW) - (cX + cW), t = cY - pY, b = (pY + pH) - (cY + cH);
|
||
if (t > 0) ctx.drawImage(srcImg, 0, 0, sw, 1, cX, pY, cW, t);
|
||
if (b > 0) ctx.drawImage(srcImg, 0, sh - 1, sw, 1, cX, cY + cH, cW, b);
|
||
if (l > 0) ctx.drawImage(srcImg, 0, 0, 1, sh, pX, cY, l, cH);
|
||
if (r > 0) ctx.drawImage(srcImg, sw - 1, 0, 1, sh, cX + cW, cY, r, cH);
|
||
if (l > 0 && t > 0) ctx.drawImage(srcImg, 0, 0, 1, 1, pX, pY, l, t);
|
||
if (r > 0 && t > 0) ctx.drawImage(srcImg, sw - 1, 0, 1, 1, cX + cW, pY, r, t);
|
||
if (l > 0 && b > 0) ctx.drawImage(srcImg, 0, sh - 1, 1, 1, pX, cY + cH, l, b);
|
||
if (r > 0 && b > 0) ctx.drawImage(srcImg, sw - 1, sh - 1, 1, 1, cX + cW, cY + cH, r, b);
|
||
} else if (em === "repeat" || em === "mirror") {
|
||
// Copy srcImg to _mirrorCanvas first — srcImg may be _tmpCanvas
|
||
const tileW = Math.max(1, Math.round(cW));
|
||
const tileH = Math.max(1, Math.round(cH));
|
||
_mirrorCanvas.width = tileW;
|
||
_mirrorCanvas.height = tileH;
|
||
_mirrorCtx.drawImage(srcImg, 0, 0, tileW, tileH);
|
||
let tileCanvas;
|
||
if (em === "mirror") {
|
||
_tmpCanvas.width = tileW * 2; _tmpCanvas.height = tileH * 2;
|
||
_tmpCtx.drawImage(_mirrorCanvas, 0, 0);
|
||
_tmpCtx.save(); _tmpCtx.translate(_tmpCanvas.width, 0); _tmpCtx.scale(-1, 1); _tmpCtx.drawImage(_mirrorCanvas, 0, 0); _tmpCtx.restore();
|
||
_tmpCtx.save(); _tmpCtx.translate(0, _tmpCanvas.height); _tmpCtx.scale(1, -1); _tmpCtx.drawImage(_mirrorCanvas, 0, 0); _tmpCtx.restore();
|
||
_tmpCtx.save(); _tmpCtx.translate(_tmpCanvas.width, _tmpCanvas.height); _tmpCtx.scale(-1, -1); _tmpCtx.drawImage(_mirrorCanvas, 0, 0); _tmpCtx.restore();
|
||
tileCanvas = _tmpCanvas;
|
||
} else {
|
||
tileCanvas = _mirrorCanvas;
|
||
}
|
||
const pattern = ctx.createPattern(tileCanvas, "repeat");
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.rect(pX, pY, pW, pH); ctx.clip();
|
||
ctx.translate(cX, cY);
|
||
ctx.fillStyle = pattern;
|
||
ctx.fillRect(pX - cX, pY - cY, pW, pH);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
|
||
function fitAspect(targetAR, cw, ch, cx = 0, cy = 0) {
|
||
if (targetAR > cw / ch) { const pH = cw / targetAR; return { pW: cw, pH, pX: cx, pY: cy + (ch - pH) / 2 }; }
|
||
const pW = ch * targetAR; return { pW, pH: ch, pX: cx + (cw - pW) / 2, pY: cy };
|
||
}
|
||
|
||
function drawDashedBorder(ctx, x, y, w, h, color = "rgba(255, 180, 60, 0.8)") {
|
||
ctx.setLineDash([4, 4]); ctx.strokeStyle = color; ctx.lineWidth = 1;
|
||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
|
||
}
|
||
|
||
// Dim four sides around an inner rect within an outer rect
|
||
function dimSides(ctx, ox, oy, ow, oh, ix, iy, iw, ih, color = "rgba(255, 80, 80, 0.3)") {
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(ox, oy, ow, iy - oy); // top
|
||
ctx.fillRect(ox, iy + ih, ow, (oy + oh) - (iy + ih)); // bottom
|
||
ctx.fillRect(ox, iy, ix - ox, ih); // left
|
||
ctx.fillRect(ix + iw, iy, (ox + ow) - (ix + iw), ih); // right
|
||
}
|
||
|
||
// Compute pad mode layout: fit target AR, compute content rect inside
|
||
function computePadLayout(rw, rh, cropW, cropH, fitW, fitH, fitX, fitY) {
|
||
const { pW, pH, pX, pY } = fitAspect(rw / rh, fitW, fitH, fitX, fitY);
|
||
const imgRatio = Math.min(rw / cropW, rh / cropH);
|
||
const cW = pW * (cropW * imgRatio / rw), cH = pH * (cropH * imgRatio / rh);
|
||
const { cX, cY } = computePadContentPos(pX, pY, pW, pH, cW, cH);
|
||
return { pW, pH, pX, pY, cW, cH, cX, cY };
|
||
}
|
||
|
||
// Render source to _tmpCanvas at given size. If srcImg + source rect provided, draws that region.
|
||
// Otherwise draws the rotated preview (with replicate rotation for pad_edge mode).
|
||
// replicate: force replicate-pad rotation (for keep_proportion pad_edge mode)
|
||
function renderToTmp(w, h, srcImg, sx, sy, sw, sh, replicate) {
|
||
const ep = getExtraPadding();
|
||
if (!srcImg && node._rotation !== 0 && (replicate || (ep.mode === "pad_edge" && ep.isFirst))) {
|
||
renderReplicateRotated(w, h); return;
|
||
}
|
||
_tmpCanvas.width = w; _tmpCanvas.height = h;
|
||
if (srcImg) _tmpCtx.drawImage(srcImg, sx, sy, sw, sh, 0, 0, w, h);
|
||
else _tmpCtx.drawImage(getRotatedPreview(), 0, 0, _rotCanvas.width, _rotCanvas.height, 0, 0, w, h);
|
||
}
|
||
|
||
function getEdgeEp() {
|
||
return { mode: "pad_edge", edgeMode: _getSubWidget("keep_proportion.edge_mode")?.value || "clamp" };
|
||
}
|
||
|
||
function computePadContentPos(pX, pY, pW, pH, cW, cH) {
|
||
const { x, y } = getPadXY();
|
||
return { cX: pX + (pW - cW) * x, cY: pY + (pH - cH) * y };
|
||
}
|
||
|
||
function getOtherBboxEdges(axis) {
|
||
const edges = [];
|
||
for (let i = 0; i < node._bboxes.length; i++) {
|
||
if (i === node._activeIdx) continue;
|
||
const { x1, y1, x2, y2 } = normBbox(node._bboxes[i]);
|
||
edges.push(axis === "x" ? x1 : y1, axis === "x" ? x2 : y2);
|
||
}
|
||
return edges;
|
||
}
|
||
|
||
function getEffectiveSnap() {
|
||
const g = getGridParams();
|
||
if (g) return { x: g.effGridW, y: g.effGridH };
|
||
const d = getDivBy();
|
||
return d > 1 ? { x: d, y: d } : { x: 0, y: 0 };
|
||
}
|
||
|
||
function snapCoord(val, snapSize, scale, canvasDim, otherEdges) {
|
||
let best = val, bestDist = Infinity;
|
||
const consider = (c) => { const d = Math.abs(val - c); if (d < bestDist) { bestDist = d; best = c; } };
|
||
consider(0);
|
||
consider(canvasDim);
|
||
if (snapSize > 1) consider(Math.round(val * scale / snapSize) * snapSize / scale);
|
||
if (otherEdges) for (const edge of otherEdges) consider(edge);
|
||
return best;
|
||
}
|
||
|
||
// Snap the moving corner(s) of a bbox to grid / edges / other bboxes.
|
||
function snapBbox(bbox) {
|
||
if (!bbox) return bbox;
|
||
const snap = getEffectiveSnap(), scale = getCanvasScale();
|
||
const xEdges = getOtherBboxEdges("x"), yEdges = getOtherBboxEdges("y");
|
||
return { startX: snapCoord(bbox.startX, snap.x, scale.x, canvasEl.width, xEdges),
|
||
startY: snapCoord(bbox.startY, snap.y, scale.y, canvasEl.height, yEdges),
|
||
endX: snapCoord(bbox.endX, snap.x, scale.x, canvasEl.width, xEdges),
|
||
endY: snapCoord(bbox.endY, snap.y, scale.y, canvasEl.height, yEdges) };
|
||
}
|
||
|
||
function snapBboxPosition(bbox) {
|
||
if (!bbox) return bbox;
|
||
const snap = getEffectiveSnap();
|
||
const scale = getCanvasScale();
|
||
const xEdges = getOtherBboxEdges("x");
|
||
const yEdges = getOtherBboxEdges("y");
|
||
const { x1, y1 } = normBbox(bbox);
|
||
const w = Math.abs(bbox.endX - bbox.startX);
|
||
const h = Math.abs(bbox.endY - bbox.startY);
|
||
// Snap both the left/top and right/bottom edges, pick whichever is closer
|
||
const snappedX1 = snapCoord(x1, snap.x, scale.x, canvasEl.width, xEdges);
|
||
const snappedX2 = snapCoord(x1 + w, snap.x, scale.x, canvasEl.width, xEdges) - w;
|
||
const snappedX = Math.abs(snappedX1 - x1) <= Math.abs(snappedX2 - x1) ? snappedX1 : snappedX2;
|
||
const snappedY1 = snapCoord(y1, snap.y, scale.y, canvasEl.height, yEdges);
|
||
const snappedY2 = snapCoord(y1 + h, snap.y, scale.y, canvasEl.height, yEdges) - h;
|
||
const snappedY = Math.abs(snappedY1 - y1) <= Math.abs(snappedY2 - y1) ? snappedY1 : snappedY2;
|
||
return { startX: snappedX, startY: snappedY, endX: snappedX + w, endY: snappedY + h };
|
||
}
|
||
|
||
function constrainAspect(bbox, dragMode) {
|
||
if (!bbox) return bbox;
|
||
const { w: tw, h: th } = getTgt();
|
||
// Target aspect in source-image pixels
|
||
const srcAR = (tw > 0 && th > 0) ? tw / th : 1;
|
||
// Convert to preview-pixel aspect by accounting for non-uniform scaling
|
||
const scale = getCanvasScale();
|
||
const ar = srcAR * scale.y / scale.x;
|
||
|
||
const { x1, y1, x2, y2 } = normBbox(bbox);
|
||
let bw = x2 - x1;
|
||
let bh = y2 - y1;
|
||
if (bw <= 0 || bh <= 0) return bbox;
|
||
|
||
// Adjust height to match aspect, keep width
|
||
const hFromW = bw / ar;
|
||
const wFromH = bh * ar;
|
||
// Pick the smaller fit so we don't exceed the drawn extent
|
||
if (hFromW <= bh) {
|
||
bh = hFromW;
|
||
} else {
|
||
bw = wFromH;
|
||
}
|
||
|
||
// Anchor based on drag mode
|
||
if (dragMode === "resize-tl") {
|
||
return { startX: x2 - bw, startY: y2 - bh, endX: x2, endY: y2 };
|
||
} else if (dragMode === "resize-tr") {
|
||
return { startX: x1, startY: y2 - bh, endX: x1 + bw, endY: y2 };
|
||
} else if (dragMode === "resize-bl") {
|
||
return { startX: x2 - bw, startY: y1, endX: x2, endY: y1 + bh };
|
||
} else {
|
||
// resize-br or draw (anchor top-left)
|
||
return { startX: x1, startY: y1, endX: x1 + bw, endY: y1 + bh };
|
||
}
|
||
}
|
||
|
||
function removeBbox(index) {
|
||
node._bboxes.splice(index, 1);
|
||
if (node._bboxes.length === 0) node._activeIdx = -1;
|
||
else if (index <= node._activeIdx) node._activeIdx = Math.min(node._activeIdx - (index < node._activeIdx ? 1 : 0), node._bboxes.length - 1);
|
||
}
|
||
|
||
function hitTestBboxes(mx, my) {
|
||
const order = node._activeIdx >= 0 ? [node._activeIdx] : [];
|
||
for (let i = 0; i < node._bboxes.length; i++) { if (i !== node._activeIdx) order.push(i); }
|
||
for (const idx of order) {
|
||
const { x1, y1, x2, y2 } = normBbox(node._bboxes[idx]);
|
||
const mode = rectHitTest(mx, my, x1, y1, x2, y2, 10);
|
||
if (mode) return { index: idx, mode };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
canvasEl.addEventListener("mousedown", (e) => {
|
||
canvasEl.focus();
|
||
if (e.button === 2) {
|
||
e.preventDefault();
|
||
const m = getCanvasMouse(e);
|
||
// Right-click on rotation cross: reset rotation
|
||
if (node._rotation !== 0 && hitTestRotationCross(m.x, m.y)) {
|
||
resetRotation();
|
||
return;
|
||
}
|
||
// Right-click: delete the bbox under cursor, or active bbox
|
||
const hit = hitTestBboxes(m.x, m.y);
|
||
if (hit) {
|
||
removeBbox(hit.index);
|
||
} else if (node._activeIdx >= 0) {
|
||
removeBbox(node._activeIdx);
|
||
}
|
||
updateBboxWidgets();
|
||
drawCanvas();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
const m = getCanvasMouse(e);
|
||
|
||
// Check for rotation cross drag (click near the center or any arm)
|
||
if (hitTestRotationCross(m.x, m.y)) {
|
||
node._dragMode = "rotate";
|
||
node._rotateStart = node._rotation;
|
||
const rect0 = canvasEl.getBoundingClientRect();
|
||
// Capture screen center once — immune to canvas resize
|
||
node._rotateCenterX = rect0.left + rect0.width / 2;
|
||
node._rotateCenterY = rect0.top + rect0.height / 2;
|
||
node._rotateScreenStartAngle = Math.atan2(
|
||
e.clientY - node._rotateCenterY,
|
||
e.clientX - node._rotateCenterX
|
||
) * 180 / Math.PI;
|
||
node._drawing = true;
|
||
node._dragStart = m;
|
||
document.addEventListener("mousemove", onDragMove);
|
||
document.addEventListener("mouseup", onDragEnd);
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
// Shift+drag: drag content position within padded area
|
||
if (e.shiftKey) {
|
||
const kpVal = getKp();
|
||
const { w: tW2, h: tH2 } = getTgt();
|
||
const ep = getExtraPadding();
|
||
const startDrag = (mode, extra) => {
|
||
node._dragMode = mode; Object.assign(node, extra);
|
||
node._drawing = true; node._dragStart = m;
|
||
document.addEventListener("mousemove", onDragMove);
|
||
document.addEventListener("mouseup", onDragEnd);
|
||
e.preventDefault(); e.stopPropagation();
|
||
};
|
||
if ((tW2 > 0 || tH2 > 0) && isPadMode(kpVal)) {
|
||
startDrag("pad_drag", { _padDragStart: getPadXY(), _bboxAtDragStart: null }); return;
|
||
} else if (hasExtraPad(ep)) {
|
||
startDrag("extra_pad_drag", { _extraPadStart: { ...ep }, _bboxAtDragStart: null }); return;
|
||
}
|
||
}
|
||
|
||
const hit = hitTestBboxes(m.x, m.y);
|
||
if (hit) {
|
||
// Select and interact with existing bbox
|
||
node._activeIdx = hit.index;
|
||
node._dragMode = hit.mode;
|
||
const bbox = node._bboxes[hit.index];
|
||
const { x1, y1, x2, y2 } = normBbox(bbox);
|
||
node._bboxAtDragStart = { startX: x1, startY: y1, endX: x2, endY: y2 };
|
||
} else {
|
||
// Draw new bbox
|
||
node._dragMode = "draw";
|
||
const newBbox = { startX: m.x, startY: m.y, endX: m.x, endY: m.y };
|
||
node._bboxes.push(newBbox);
|
||
node._activeIdx = node._bboxes.length - 1;
|
||
node._bboxAtDragStart = null;
|
||
}
|
||
node._drawing = true;
|
||
node._dragStart = m;
|
||
|
||
document.addEventListener("mousemove", onDragMove);
|
||
document.addEventListener("mouseup", onDragEnd);
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
});
|
||
|
||
// Hover cursor updates
|
||
canvasEl.addEventListener("mousemove", (e) => {
|
||
if (node._drawing) return;
|
||
const m = getCanvasMouse(e);
|
||
const hit = hitTestBboxes(m.x, m.y);
|
||
if (!hit) {
|
||
if (!node._showRotationCross) {
|
||
canvasEl.style.cursor = e.shiftKey ? "grab" : "crosshair";
|
||
} else if (hitTestRotationCross(m.x, m.y)) {
|
||
canvasEl.style.cursor = "alias";
|
||
} else if (e.shiftKey) {
|
||
const kpVal = getKp();
|
||
const { w: tW, h: tH } = getTgt();
|
||
const epHover = getExtraPadding();
|
||
if (((tW > 0 || tH > 0) && isPadMode(kpVal)) || hasExtraPad(epHover)) {
|
||
canvasEl.style.cursor = "grab";
|
||
} else {
|
||
canvasEl.style.cursor = "crosshair";
|
||
}
|
||
} else {
|
||
canvasEl.style.cursor = "crosshair";
|
||
}
|
||
} else {
|
||
canvasEl.style.cursor = cursorForBboxMode(hit.mode) || "crosshair";
|
||
}
|
||
});
|
||
|
||
function onDragMove(e) {
|
||
if (!node._drawing) return;
|
||
if (node._dragMode === "rotate") {
|
||
// Screen-space atan2 with fixed center (captured at drag start)
|
||
const sdx = e.clientX - node._rotateCenterX;
|
||
const sdy = e.clientY - node._rotateCenterY;
|
||
const screenDist = Math.sqrt(sdx * sdx + sdy * sdy);
|
||
if (screenDist < 15) {
|
||
node._rotateStart = node._rotation;
|
||
node._rotateScreenStartAngle = Math.atan2(sdy, sdx) * 180 / Math.PI;
|
||
return;
|
||
}
|
||
const angleCur = Math.atan2(sdy, sdx) * 180 / Math.PI;
|
||
let angleDelta = angleCur - node._rotateScreenStartAngle;
|
||
// Normalize delta to -180..180 to handle atan2 wrap-around
|
||
while (angleDelta > 180) angleDelta -= 360;
|
||
while (angleDelta < -180) angleDelta += 360;
|
||
let newRot = node._rotateStart + angleDelta;
|
||
if (e.ctrlKey) {
|
||
newRot = Math.round(newRot / 15) * 15;
|
||
} else {
|
||
for (const snap of [0, 90, 180, 270, -90, -180, -270, 360]) {
|
||
if (Math.abs(newRot - snap) < 3) { newRot = snap; break; }
|
||
}
|
||
}
|
||
while (newRot > 180) newRot -= 360;
|
||
while (newRot < -180) newRot += 360;
|
||
node._rotation = newRot;
|
||
node.properties.rotation = newRot;
|
||
// Resize canvas to match rotated image dimensions
|
||
if (node._previewImg && node._previewEnabled) {
|
||
const { w: ew, h: eh } = getEffectiveImageDims(true);
|
||
const [rw, rh] = clampToMaxCanvas(ew, eh);
|
||
if (Math.abs(rw - canvasEl.width) > 3 || Math.abs(rh - canvasEl.height) > 3) {
|
||
setCanvasSize(rw, rh);
|
||
}
|
||
}
|
||
drawCanvas();
|
||
return;
|
||
}
|
||
if (node._dragMode === "pad_drag") {
|
||
const m = getCanvasMouse(e);
|
||
const dx = m.x - node._dragStart.x;
|
||
const dy = m.y - node._dragStart.y;
|
||
// Compute the pad preview layout to find how many canvas pixels = full 0-1 range
|
||
const ep = getExtraPadding();
|
||
const { w: imgW, h: imgH } = node._previewImg
|
||
? getEffectiveImageDims(getKp() !== "pad_edge")
|
||
: { w: canvasEl.width, h: canvasEl.height };
|
||
const { rw: baseTw, rh: baseTh } = getEffectiveTargetDims(imgW, imgH);
|
||
const totalW = baseTw + ep.left + ep.right;
|
||
const totalH = baseTh + ep.top + ep.bottom;
|
||
const { pW, pH } = fitAspect(totalW / totalH, canvasEl.width, canvasEl.height);
|
||
const imgRatio = Math.min(baseTw / imgW, baseTh / imgH);
|
||
const cW = pW * (imgW * imgRatio / totalW), cH = pH * (imgH * imgRatio / totalH);
|
||
const rangeX = pW - cW, rangeY = pH - cH;
|
||
const newX = rangeX > 0 ? node._padDragStart.x + dx / rangeX : 0.5;
|
||
const newY = rangeY > 0 ? node._padDragStart.y + dy / rangeY : 0.5;
|
||
setPadXY(newX, newY, true);
|
||
drawCanvas();
|
||
return;
|
||
}
|
||
if (node._dragMode === "extra_pad_drag") {
|
||
const m = getCanvasMouse(e);
|
||
const dx = m.x - node._dragStart.x;
|
||
const dy = m.y - node._dragStart.y;
|
||
const s = node._extraPadStart;
|
||
const totalH = s.top + s.bottom, totalW = s.left + s.right;
|
||
const fullW = (node._previewImg ? node._previewImg.naturalWidth : canvasEl.width) + totalW;
|
||
const fullH = (node._previewImg ? node._previewImg.naturalHeight : canvasEl.height) + totalH;
|
||
const scale = (fullW / fullH > canvasEl.width / canvasEl.height)
|
||
? fullW / canvasEl.width : fullH / canvasEl.height;
|
||
const pixDx = Math.round(dx * scale);
|
||
const pixDy = Math.round(dy * scale);
|
||
const newLeft = Math.max(0, Math.min(totalW, s.left + pixDx));
|
||
const newTop = Math.max(0, Math.min(totalH, s.top + pixDy));
|
||
for (const [name, val] of [["pad_left", newLeft], ["pad_right", totalW - newLeft],
|
||
["pad_top", newTop], ["pad_bottom", totalH - newTop]]) {
|
||
const w = _getSubWidget(`extra_padding.${name}`);
|
||
if (w) w.value = val;
|
||
}
|
||
drawCanvas();
|
||
return;
|
||
}
|
||
if (node._activeIdx < 0) return;
|
||
const m = getCanvasMouse(e);
|
||
const cw = canvasEl.width;
|
||
const ch = canvasEl.height;
|
||
let bbox = node._bboxes[node._activeIdx];
|
||
|
||
if (node._dragMode === "draw") {
|
||
bbox.endX = Math.max(0, Math.min(cw, m.x));
|
||
bbox.endY = Math.max(0, Math.min(ch, m.y));
|
||
} else if (node._dragMode === "move" && node._bboxAtDragStart) {
|
||
const dx = m.x - node._dragStart.x;
|
||
const dy = m.y - node._dragStart.y;
|
||
const bs = node._bboxAtDragStart;
|
||
const w = bs.endX - bs.startX;
|
||
const h = bs.endY - bs.startY;
|
||
const nx = Math.max(0, Math.min(cw - w, bs.startX + dx));
|
||
const ny = Math.max(0, Math.min(ch - h, bs.startY + dy));
|
||
bbox = { startX: nx, startY: ny, endX: nx + w, endY: ny + h };
|
||
} else if (node._dragMode?.startsWith("resize") && node._bboxAtDragStart) {
|
||
const bs = node._bboxAtDragStart;
|
||
const dm = node._dragMode;
|
||
// Edge resize: only one axis moves
|
||
if (dm === "resize-t" || dm === "resize-b" || dm === "resize-l" || dm === "resize-r") {
|
||
const edgeCoord = dm === "resize-t" ? bs.startY : dm === "resize-b" ? bs.endY : dm === "resize-l" ? bs.startX : bs.endX;
|
||
const isVert = dm === "resize-t" || dm === "resize-b";
|
||
const off = (isVert ? node._dragStart.y : node._dragStart.x) - edgeCoord;
|
||
const val = Math.max(0, Math.min(isVert ? ch : cw, (isVert ? m.y : m.x) - off));
|
||
if (dm === "resize-t") bbox = { startX: bs.startX, startY: val, endX: bs.endX, endY: bs.endY };
|
||
else if (dm === "resize-b") bbox = { startX: bs.startX, startY: bs.startY, endX: bs.endX, endY: val };
|
||
else if (dm === "resize-l") bbox = { startX: val, startY: bs.startY, endX: bs.endX, endY: bs.endY };
|
||
else bbox = { startX: bs.startX, startY: bs.startY, endX: val, endY: bs.endY };
|
||
} else {
|
||
// Corner resize
|
||
let cornerX, cornerY;
|
||
if (dm === "resize-tl") { cornerX = bs.startX; cornerY = bs.startY; }
|
||
else if (dm === "resize-tr") { cornerX = bs.endX; cornerY = bs.startY; }
|
||
else if (dm === "resize-bl") { cornerX = bs.startX; cornerY = bs.endY; }
|
||
else { cornerX = bs.endX; cornerY = bs.endY; }
|
||
const offX = node._dragStart.x - cornerX;
|
||
const offY = node._dragStart.y - cornerY;
|
||
const cx = Math.max(0, Math.min(cw, m.x - offX));
|
||
const cy = Math.max(0, Math.min(ch, m.y - offY));
|
||
if (dm === "resize-tl") bbox = { startX: bs.endX, startY: bs.endY, endX: cx, endY: cy };
|
||
else if (dm === "resize-tr") bbox = { startX: bs.startX, startY: bs.endY, endX: cx, endY: cy };
|
||
else if (dm === "resize-bl") bbox = { startX: bs.endX, startY: bs.startY, endX: cx, endY: cy };
|
||
else if (dm === "resize-br") bbox = { startX: bs.startX, startY: bs.startY, endX: cx, endY: cy };
|
||
}
|
||
}
|
||
|
||
// Alt: resize symmetrically — for draw, the click point is center;
|
||
// for resize, the center of the original bbox is the pivot
|
||
if (e.altKey && node._dragMode !== "move") {
|
||
let cx, cy;
|
||
if (node._dragMode === "draw") {
|
||
cx = node._dragStart.x;
|
||
cy = node._dragStart.y;
|
||
} else if (node._bboxAtDragStart) {
|
||
const bs = node._bboxAtDragStart;
|
||
cx = (bs.startX + bs.endX) / 2;
|
||
cy = (bs.startY + bs.endY) / 2;
|
||
} else {
|
||
cx = (bbox.startX + bbox.endX) / 2;
|
||
cy = (bbox.startY + bbox.endY) / 2;
|
||
}
|
||
const dx = Math.abs(bbox.endX - cx);
|
||
const dy = Math.abs(bbox.endY - cy);
|
||
bbox = {
|
||
startX: Math.max(0, cx - dx),
|
||
startY: Math.max(0, cy - dy),
|
||
endX: Math.min(cw, cx + dx),
|
||
endY: Math.min(ch, cy + dy),
|
||
};
|
||
}
|
||
|
||
// Shift: constrain to target aspect ratio (not applicable to edge resize)
|
||
const isEdgeResize = /^resize-[tblr]$/.test(node._dragMode);
|
||
if (e.shiftKey && node._dragMode !== "move" && !isEdgeResize) {
|
||
bbox = constrainAspect(bbox, node._dragMode);
|
||
}
|
||
|
||
// Ctrl: snap to grid in source image space
|
||
if (e.ctrlKey) {
|
||
if (node._dragMode === "move") {
|
||
bbox = snapBboxPosition(bbox);
|
||
} else if (e.altKey) {
|
||
// Snap both edges, keep centered
|
||
const snap = getEffectiveSnap();
|
||
const scale = getCanvasScale();
|
||
const xEdges = getOtherBboxEdges("x");
|
||
const yEdges = getOtherBboxEdges("y");
|
||
const sx1 = snapCoord(bbox.startX, snap.x, scale.x, cw, xEdges);
|
||
const sx2 = snapCoord(bbox.endX, snap.x, scale.x, cw, xEdges);
|
||
const sy1 = snapCoord(bbox.startY, snap.y, scale.y, ch, yEdges);
|
||
const sy2 = snapCoord(bbox.endY, snap.y, scale.y, ch, yEdges);
|
||
bbox = { startX: sx1, startY: sy1, endX: sx2, endY: sy2 };
|
||
} else if (isEdgeResize) {
|
||
// Only snap the edge being dragged
|
||
const snap = getEffectiveSnap();
|
||
const scale = getCanvasScale();
|
||
const xEdges = getOtherBboxEdges("x"), yEdges = getOtherBboxEdges("y");
|
||
const dm = node._dragMode;
|
||
bbox = { ...bbox,
|
||
...(dm === "resize-t" && { startY: snapCoord(bbox.startY, snap.y, scale.y, ch, yEdges) }),
|
||
...(dm === "resize-b" && { endY: snapCoord(bbox.endY, snap.y, scale.y, ch, yEdges) }),
|
||
...(dm === "resize-l" && { startX: snapCoord(bbox.startX, snap.x, scale.x, cw, xEdges) }),
|
||
...(dm === "resize-r" && { endX: snapCoord(bbox.endX, snap.x, scale.x, cw, xEdges) }),
|
||
};
|
||
} else {
|
||
bbox = snapBbox(bbox);
|
||
}
|
||
}
|
||
|
||
node._bboxes[node._activeIdx] = bbox;
|
||
drawCanvas();
|
||
e.preventDefault();
|
||
}
|
||
|
||
function onDragEnd(e) {
|
||
if (!node._drawing) return;
|
||
const wasRotate = node._dragMode === "rotate";
|
||
const wasPadDrag = node._dragMode === "pad_drag";
|
||
Object.assign(node, { _drawing: false, _dragMode: null, _dragStart: null,
|
||
_bboxAtDragStart: null, _padDragStart: null, _extraPadStart: null });
|
||
// Flush deferred pad_x/pad_y widget values
|
||
if (wasPadDrag) setPadXY(_framePadXY.x, _framePadXY.y);
|
||
document.removeEventListener("mousemove", onDragMove);
|
||
document.removeEventListener("mouseup", onDragEnd);
|
||
|
||
// Snap canvas to exact rotated dimensions on release (during drag we use a threshold to avoid jitter)
|
||
if (wasRotate) {
|
||
restoreCanvasToImage();
|
||
fitNodeToImage(false);
|
||
}
|
||
|
||
if (node._activeIdx >= 0) {
|
||
const bbox = node._bboxes[node._activeIdx];
|
||
const { x1, y1, x2, y2 } = normBbox(bbox);
|
||
if (x2 - x1 < 3 && y2 - y1 < 3) {
|
||
// Too small — remove it
|
||
removeBbox(node._activeIdx);
|
||
} else {
|
||
node._bboxes[node._activeIdx] = { startX: x1, startY: y1, endX: x2, endY: y2 };
|
||
}
|
||
}
|
||
updateBboxWidgets();
|
||
drawCanvas();
|
||
}
|
||
|
||
canvasEl.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); });
|
||
|
||
canvasEl.tabIndex = 0;
|
||
canvasEl.style.outline = "none";
|
||
canvasEl.addEventListener("keydown", (e) => {
|
||
if (node._activeIdx < 0) return;
|
||
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||
const dir = arrows[e.key];
|
||
if (!dir) {
|
||
// Delete key removes active bbox
|
||
if (e.key === "Delete" || e.key === "Backspace") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
removeBbox(node._activeIdx);
|
||
updateBboxWidgets();
|
||
drawCanvas();
|
||
}
|
||
// Tab cycles through bboxes
|
||
if (e.key === "Tab" && node._bboxes.length > 1) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
node._activeIdx = (node._activeIdx + (e.shiftKey ? -1 : 1) + node._bboxes.length) % node._bboxes.length;
|
||
drawCanvas();
|
||
}
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const snap = getEffectiveSnap();
|
||
const step = e.ctrlKey ? (snap.x > 1 ? snap.x / getCanvasScale().x : 10) : 1;
|
||
const stepY = e.ctrlKey ? (snap.y > 1 ? snap.y / getCanvasScale().y : 10) : 1;
|
||
const dx = dir[0] * step;
|
||
const dy = dir[1] * stepY;
|
||
const bbox = node._bboxes[node._activeIdx];
|
||
const { x1, y1, x2, y2 } = normBbox(bbox);
|
||
const w = x2 - x1, h = y2 - y1;
|
||
const nx = Math.max(0, Math.min(canvasEl.width - w, x1 + dx));
|
||
const ny = Math.max(0, Math.min(canvasEl.height - h, y1 + dy));
|
||
node._bboxes[node._activeIdx] = { startX: nx, startY: ny, endX: nx + w, endY: ny + h };
|
||
updateBboxWidgets();
|
||
drawCanvas();
|
||
});
|
||
|
||
function updateBboxWidgets() {
|
||
const data = {};
|
||
if (node._rotation !== 0) data.rotation = node._rotation;
|
||
data.fillColor = getFillColor();
|
||
if (node._bboxes.length > 0) {
|
||
data.bboxes = node._bboxes.map((bbox) => {
|
||
const b = Object.assign({}, bbox);
|
||
if (node._previewImg) {
|
||
b.previewWidth = canvasEl.width;
|
||
b.previewHeight = canvasEl.height;
|
||
}
|
||
return b;
|
||
});
|
||
}
|
||
bboxWidget.value = Object.keys(data).length > 0 ? JSON.stringify(data) : "";
|
||
}
|
||
|
||
let _resizing = false;
|
||
function fitNodeToCanvas() {
|
||
if (canvasEl.width <= 0 || canvasEl.height <= 0) return;
|
||
// If in a no-bbox mode with target dims, resize canvas to output AR
|
||
const kp = getKp();
|
||
const { w: tgtW, h: tgtH } = getTgt();
|
||
const hasBboxes = node._bboxes.length > 0;
|
||
if (!hasBboxes && node._previewImg && node._previewEnabled && node._rotation === 0 && (tgtW > 0 || tgtH > 0) &&
|
||
(kp === "stretch" || isPadMode(kp))) {
|
||
const { w: imgW, h: imgH } = getEffectiveImageDims(kp !== "pad_edge");
|
||
let rw = tgtW > 0 ? tgtW : imgW;
|
||
let rh = tgtH > 0 ? tgtH : imgH;
|
||
const outAR = rw / rh;
|
||
const newH = Math.round(canvasEl.width / outAR);
|
||
if (Math.abs(newH - canvasEl.height) > 2) {
|
||
canvasEl.height = newH;
|
||
canvasEl.style.aspectRatio = `${canvasEl.width} / ${newH}`;
|
||
_rotCacheKey = "";
|
||
}
|
||
} else if (node._previewImg && node._previewEnabled) {
|
||
// Restore to image AR (including pad-first extra padding if active)
|
||
let { w: iw, h: ih } = getEffectiveImageDims(true);
|
||
const ep = getExtraPadding();
|
||
if (ep.isFirst && (ep.top + ep.bottom + ep.left + ep.right) > 0) {
|
||
iw += ep.left + ep.right;
|
||
ih += ep.top + ep.bottom;
|
||
}
|
||
const imgAR = iw / ih;
|
||
const newH = Math.round(canvasEl.width / imgAR);
|
||
if (Math.abs(newH - canvasEl.height) > 2) {
|
||
rescaleBboxesTo(canvasEl.width, newH);
|
||
canvasEl.height = newH;
|
||
canvasEl.style.aspectRatio = `${canvasEl.width} / ${newH}`;
|
||
_rotCacheKey = "";
|
||
}
|
||
}
|
||
// Update widget height but don't force node size — only grow if needed
|
||
const nodeW = node.size[0];
|
||
const displayedW = nodeW - 30;
|
||
const displayedH = Math.round(canvasEl.height * (displayedW / canvasEl.width));
|
||
node._widgetHeight = displayedH + GRID_BAR_HEIGHT;
|
||
}
|
||
|
||
// Bbox coords are stored in current canvas-pixel space. Whenever the canvas
|
||
// buffer is resized (preview load, node resize, AR change) the boxes must be
|
||
// rescaled by the same ratio so they stay locked to the image content.
|
||
function rescaleBboxesTo(newW, newH) {
|
||
const oldW = canvasEl.width, oldH = canvasEl.height;
|
||
if (!oldW || !oldH || !newW || !newH || !node._bboxes.length) return;
|
||
if (oldW === newW && oldH === newH) return;
|
||
const sx = newW / oldW, sy = newH / oldH;
|
||
node._bboxes = node._bboxes.map((b) => ({
|
||
startX: b.startX * sx, startY: b.startY * sy,
|
||
endX: b.endX * sx, endY: b.endY * sy,
|
||
}));
|
||
}
|
||
|
||
function setCanvasSize(cw, ch) {
|
||
rescaleBboxesTo(cw, ch);
|
||
canvasEl.width = cw;
|
||
canvasEl.height = ch;
|
||
canvasEl.style.aspectRatio = `${cw} / ${ch}`;
|
||
node.properties.canvasSize = [cw, ch];
|
||
node._widgetHeight = ch + GRID_BAR_HEIGHT;
|
||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||
fitNodeToCanvas();
|
||
}
|
||
|
||
function updateCanvasFromTargetDims() {
|
||
if (node._previewImg && node._previewEnabled) return;
|
||
const divBy = getDivBy();
|
||
const { w: twV, h: thV } = getTgt();
|
||
let tw = roundDown(twV, divBy);
|
||
let th = roundDown(thV, divBy);
|
||
const [cw, ch] = clampToMaxCanvas(tw > 0 ? Math.max(tw, 200) : 400, th > 0 ? Math.max(th, 150) : 300);
|
||
if (cw !== canvasEl.width || ch !== canvasEl.height) {
|
||
setCanvasSize(cw, ch);
|
||
}
|
||
}
|
||
|
||
function getCanvasScale() {
|
||
if (node._previewImg && node._previewEnabled) {
|
||
if (node._rotation !== 0) {
|
||
// When rotated, canvas is sized to rotated bounds — uniform scale
|
||
const { w: rotW } = getEffectiveImageDims(true);
|
||
const s = rotW / canvasEl.width; // same as rotH / canvasEl.height
|
||
return { x: s, y: s };
|
||
}
|
||
let nw = node._previewImg.naturalWidth;
|
||
let nh = node._previewImg.naturalHeight;
|
||
// For "pad first" modes, bbox coords map to padded image dimensions
|
||
const ep = getExtraPadding();
|
||
if (ep.isFirst && hasExtraPad(ep)) {
|
||
nw += ep.left + ep.right;
|
||
nh += ep.top + ep.bottom;
|
||
}
|
||
return { x: nw / canvasEl.width, y: nh / canvasEl.height };
|
||
}
|
||
const { w: tw, h: th } = getTgt();
|
||
return { x: tw > 0 ? tw / canvasEl.width : 1, y: th > 0 ? th / canvasEl.height : 1 };
|
||
}
|
||
|
||
function getOutputDims(bbox) {
|
||
if (!bbox) return null;
|
||
const { x: sX, y: sY } = getCanvasScale();
|
||
const { x1, y1, x2, y2 } = normBbox(bbox);
|
||
const cropW = Math.round((x2 - x1) * sX);
|
||
const cropH = Math.round((y2 - y1) * sY);
|
||
const dims = computeOutputDims(cropW, cropH);
|
||
return dims ? { w: Math.max(1, dims.w), h: Math.max(1, dims.h) } : null;
|
||
}
|
||
|
||
function getGridParams() {
|
||
const gridSize = getGridSize();
|
||
if (gridSize <= 1) return null;
|
||
|
||
let tw, th;
|
||
if (node._previewImg && node._previewEnabled) {
|
||
const dims = getEffectiveImageDims(true);
|
||
tw = dims.w;
|
||
th = dims.h;
|
||
} else {
|
||
tw = twWidget ? twWidget.value : canvasEl.width;
|
||
th = thWidget ? thWidget.value : canvasEl.height;
|
||
}
|
||
const scaleX = canvasEl.width / tw;
|
||
const scaleY = canvasEl.height / th;
|
||
if (gridSize * scaleX < 4 && gridSize * scaleY < 4) return null;
|
||
// Per-dimension effective grid: no partial cells, cells are near-square
|
||
const cellsX = Math.max(1, Math.round(tw / gridSize));
|
||
const cellsY = Math.max(1, Math.round(th / gridSize));
|
||
const effGridW = tw / cellsX;
|
||
const effGridH = th / cellsY;
|
||
return { gridSize, tw, th, scaleX, scaleY, effGridW, effGridH };
|
||
}
|
||
|
||
function drawGrid(ctx, alpha, clipBbox, color) {
|
||
const g = getGridParams();
|
||
if (!g) return;
|
||
const { tw, th, scaleX, scaleY, effGridW, effGridH } = g;
|
||
|
||
ctx.save();
|
||
if (clipBbox) {
|
||
const { x1, y1, x2, y2 } = normBbox(clipBbox);
|
||
ctx.beginPath();
|
||
ctx.rect(x1, y1, x2 - x1, y2 - y1);
|
||
ctx.clip();
|
||
}
|
||
|
||
const showPreview = node._previewImg && node._previewEnabled;
|
||
const defaultAlpha = showPreview ? 0.25 : 0.1;
|
||
if (color) {
|
||
ctx.strokeStyle = color;
|
||
} else {
|
||
const { r, g, b } = getGridColorRGB();
|
||
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha ?? defaultAlpha})`;
|
||
}
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let rx = effGridW; rx < tw - 0.5; rx += effGridW) {
|
||
const px = Math.round(rx * scaleX) + 0.5;
|
||
ctx.moveTo(px, 0);
|
||
ctx.lineTo(px, canvasEl.height);
|
||
}
|
||
for (let ry = effGridH; ry < th - 0.5; ry += effGridH) {
|
||
const py = Math.round(ry * scaleY) + 0.5;
|
||
ctx.moveTo(0, py);
|
||
ctx.lineTo(canvasEl.width, py);
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawRotationCross(ctx) {
|
||
if (!node._showRotationCross) return;
|
||
const { width: cw, height: ch } = canvasEl;
|
||
const armLen = Math.sqrt(cw * cw + ch * ch) / 2;
|
||
const rad = rotRad();
|
||
const isActive = node._rotation !== 0;
|
||
ctx.save();
|
||
ctx.translate(cw / 2, ch / 2);
|
||
ctx.rotate(rad);
|
||
ctx.beginPath();
|
||
ctx.moveTo(-armLen, 0); ctx.lineTo(armLen, 0);
|
||
ctx.moveTo(0, -armLen); ctx.lineTo(0, armLen);
|
||
// Black outline
|
||
Object.assign(ctx, { strokeStyle: "rgba(0, 0, 0, 0.6)", lineWidth: isActive ? 5 : 4 });
|
||
ctx.stroke();
|
||
// Orange fill
|
||
Object.assign(ctx, { strokeStyle: "rgba(255, 160, 30, 0.8)", lineWidth: isActive ? 3 : 2 });
|
||
ctx.stroke();
|
||
if (isActive) {
|
||
ctx.rotate(-rad);
|
||
const label = `${node._rotation.toFixed(1)}°`;
|
||
Object.assign(ctx, { font: "12px sans-serif", textAlign: "center" });
|
||
const lw = ctx.measureText(label).width + 8;
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
|
||
ctx.beginPath(); ctx.roundRect(-lw / 2, -34, lw, 16, 3); ctx.fill();
|
||
ctx.fillStyle = "rgba(255, 160, 30, 1)";
|
||
ctx.fillText(label, 0, -20);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawBbox(ctx, bbox, index, isActive) {
|
||
const { x1, y1, x2, y2 } = normBbox(bbox);
|
||
const w = x2 - x1, h = y2 - y1;
|
||
const color = getBboxColor(index, isActive);
|
||
const invertEnabled = icWidget ? icWidget.value === "enabled" : false;
|
||
if (isActive) {
|
||
if (invertEnabled) {
|
||
ctx.fillStyle = getFillColor(); ctx.fillRect(x1, y1, w, h);
|
||
} else {
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; ctx.beginPath();
|
||
ctx.rect(0, 0, canvasEl.width, canvasEl.height); ctx.rect(x1, y1, w, h); ctx.fill("evenodd");
|
||
}
|
||
}
|
||
drawGrid(ctx, null, bbox, color.gridColor);
|
||
if (!isActive) { ctx.fillStyle = color.tint; ctx.fillRect(x1, y1, w, h); }
|
||
|
||
if (isActive && !invertEnabled) {
|
||
const { x: sX, y: sY } = getCanvasScale();
|
||
const cropW = Math.round(w * sX);
|
||
const cropH = Math.round(h * sY);
|
||
const { w: tgtW, h: tgtH } = getTgt();
|
||
const kp = getKp();
|
||
|
||
if (kp === "multiplier") {
|
||
const wmv = _getSubWidget("keep_proportion.width_mult")?.value || 1.0;
|
||
const hmv = _getSubWidget("keep_proportion.height_mult")?.value || 1.0;
|
||
if (wmv !== 1.0 || hmv !== 1.0) {
|
||
drawStretchOverlay(ctx, (cropW * wmv) / (cropH * hmv), x1, y1, w, h);
|
||
}
|
||
}
|
||
|
||
if ((tgtW > 0 || tgtH > 0) && kp === "stretch") {
|
||
const { rw, rh } = getEffectiveTargetDims(cropW, cropH);
|
||
drawStretchOverlay(ctx, rw / rh, x1, y1, w, h);
|
||
}
|
||
|
||
if ((tgtW > 0 || tgtH > 0) && (kp === "crop" || isPadMode(kp))) {
|
||
const { rw, rh } = getEffectiveTargetDims(cropW, cropH);
|
||
|
||
if (kp === "crop") {
|
||
const ratio = Math.max(rw / cropW, rh / cropH);
|
||
const visW = rw / ratio, visH = rh / ratio;
|
||
const offX = (cropW - visW) / 2, offY = (cropH - visH) / 2;
|
||
const vx1 = x1 + offX / sX, vy1 = y1 + offY / sY;
|
||
const vx2 = x1 + (offX + visW) / sX, vy2 = y1 + (offY + visH) / sY;
|
||
dimSides(ctx, x1, y1, w, h, vx1, vy1, vx2 - vx1, vy2 - vy1);
|
||
drawDashedBorder(ctx, vx1, vy1, vx2 - vx1, vy2 - vy1, "rgba(255, 255, 100, 0.8)");
|
||
} else if (isPadMode(kp)) {
|
||
const { pW, pH, pX, pY, cW, cH, cX, cY } = computePadLayout(rw, rh, cropW, cropH, w, h, x1, y1);
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||
ctx.fillRect(x1, y1, w, h);
|
||
if (kp === "pad_edge" && node._previewEnabled && node._previewImg) {
|
||
renderToTmp(Math.max(1, Math.round(cW)), Math.max(1, Math.round(cH)),
|
||
node._previewImg, x1 * sX, y1 * sY, w * sX, h * sY);
|
||
drawExtraPadFill(ctx, getEdgeEp(), pX, pY, pW, pH, cX, cY, cW, cH, _tmpCanvas);
|
||
} else if (kp === "pad_color") {
|
||
ctx.fillStyle = getFillColor();
|
||
ctx.fillRect(pX, pY, pW, pH);
|
||
}
|
||
if (node._previewEnabled) {
|
||
drawPreviewRegion(ctx, x1, y1, w, h, cX, cY, cW, cH);
|
||
}
|
||
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
|
||
}
|
||
}
|
||
|
||
const epBbox = getExtraPadding();
|
||
const padModeHandledBbox = isPadMode(kp) && (tgtW > 0 || tgtH > 0);
|
||
if (hasExtraPad(epBbox) && !padModeHandledBbox && !invertEnabled && !epBbox.isFirst && node._previewImg) {
|
||
const epContent = computeContentDims(cropW, cropH);
|
||
const epCW = epContent ? epContent.w : cropW, epCH = epContent ? epContent.h : cropH;
|
||
const totalW2 = epCW + epBbox.left + epBbox.right, totalH2 = epCH + epBbox.top + epBbox.bottom;
|
||
const { pW: pW2, pH: pH2, pX: pX2, pY: pY2 } = fitAspect(totalW2 / totalH2, w, h, x1, y1);
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(x1, y1, w, h);
|
||
const cW2 = pW2 * (epCW / totalW2), cH2 = pH2 * (epCH / totalH2);
|
||
const cX2 = pX2 + pW2 * (epBbox.left / totalW2), cY2 = pY2 + pH2 * (epBbox.top / totalH2);
|
||
// Draw crop fitted within content area (letterbox, don't stretch)
|
||
const cropAR = cropW / cropH;
|
||
const { pW: iW2, pH: iH2, pX: iX2, pY: iY2 } = fitAspect(cropAR, cW2, cH2, cX2, cY2);
|
||
const riW2 = Math.max(1, Math.round(iW2)), riH2 = Math.max(1, Math.round(iH2));
|
||
renderToTmp(riW2, riH2, node._previewImg, x1 * sX, y1 * sY, w * sX, h * sY);
|
||
drawExtraPadFill(ctx, epBbox, pX2, pY2, pW2, pH2, cX2, cY2, cW2, cH2, _tmpCanvas);
|
||
drawPreviewRegion(ctx, x1, y1, w, h, iX2, iY2, iW2, iH2);
|
||
drawDashedBorder(ctx, cX2, cY2, cW2, cH2, "rgba(100, 200, 255, 0.8)");
|
||
}
|
||
|
||
const { w: outW, h: outH } = getOutputDims(bbox) || { w: cropW, h: cropH };
|
||
const boxLabel = node._bboxes.length > 1 ? `[${index + 1}] ` : "";
|
||
const label = (tgtW > 0 || tgtH > 0)
|
||
? `${boxLabel}${cropW}×${cropH} → ${outW}×${outH}`
|
||
: `${boxLabel}${cropW} × ${cropH}`;
|
||
ctx.font = "12px sans-serif";
|
||
const lw = ctx.measureText(label).width + 12;
|
||
const lh = 20;
|
||
const ly = y1 > lh + 4 ? y1 - lh - 2 : y1 + 4;
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||
ctx.fillRect(x1, ly, lw, lh);
|
||
ctx.fillStyle = "#fff";
|
||
ctx.textAlign = "left";
|
||
ctx.fillText(label, x1 + 6, ly + 14);
|
||
} else if (!isActive) {
|
||
// Inactive: show index label centered in bbox
|
||
const boxLabel = `${index + 1}`;
|
||
ctx.font = "bold 20px sans-serif";
|
||
ctx.fillStyle = color.border;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(boxLabel, x1 + w / 2, y1 + h / 2);
|
||
ctx.textBaseline = "alphabetic";
|
||
}
|
||
|
||
if (isActive) {
|
||
const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2;
|
||
Object.assign(ctx, { strokeStyle: color.border, lineWidth: 1 });
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - 5, cy); ctx.lineTo(cx + 5, cy);
|
||
ctx.moveTo(cx, cy - 5); ctx.lineTo(cx, cy + 5);
|
||
ctx.stroke();
|
||
}
|
||
if (!isActive) { ctx.strokeStyle = "rgba(0, 0, 0, 0.6)"; ctx.lineWidth = 4; ctx.strokeRect(x1, y1, w, h); }
|
||
ctx.strokeStyle = color.border; ctx.lineWidth = 2; ctx.strokeRect(x1, y1, w, h);
|
||
|
||
ctx.fillStyle = color.handle;
|
||
const hs = isActive ? 5 : 4, s2 = hs * 2;
|
||
const cx1 = Math.max(hs, Math.min(canvasEl.width - hs, x1));
|
||
const cx2 = Math.max(hs, Math.min(canvasEl.width - hs, x2));
|
||
const cy1 = Math.max(hs, Math.min(canvasEl.height - hs, y1));
|
||
const cy2 = Math.max(hs, Math.min(canvasEl.height - hs, y2));
|
||
for (const [hx, hy] of [[cx1, cy1], [cx2, cy1], [cx1, cy2], [cx2, cy2]])
|
||
ctx.fillRect(hx - hs, hy - hs, s2, s2);
|
||
}
|
||
|
||
function drawCanvas() {
|
||
const ctx = canvasCtx;
|
||
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
|
||
_frameEp = _readExtraPadding();
|
||
if (node._dragMode !== "pad_drag") _framePadXY = _readPadXY();
|
||
const _kpVal = getKp(), { w: _tgtW, h: _tgtH } = getTgt();
|
||
const hasBboxes = node._bboxes.length > 0;
|
||
const showPreview = node._previewImg && node._previewEnabled;
|
||
const _skipFullImage = !hasBboxes && showPreview && isPadMode(_kpVal) && (_tgtW > 0 || _tgtH > 0);
|
||
|
||
const _epBase = getExtraPadding();
|
||
const _isFirst = _epBase.isFirst, _hasFirstPad = _isFirst && hasExtraPad(_epBase);
|
||
const maybeResize = (cw, ch) => {
|
||
if (cw !== canvasEl.width || ch !== canvasEl.height) { setCanvasSize(cw, ch); ctx.clearRect(0, 0, cw, ch); }
|
||
};
|
||
if (showPreview) {
|
||
if (_hasFirstPad) {
|
||
const { w: eiw, h: eih } = getEffectiveImageDims(_epBase.mode !== "pad_edge");
|
||
maybeResize(...clampToMaxCanvas(eiw + _epBase.left + _epBase.right, eih + _epBase.top + _epBase.bottom));
|
||
} else if (node._previewImg) {
|
||
maybeResize(...clampToMaxCanvas(...Object.values(getEffectiveImageDims(true))));
|
||
}
|
||
}
|
||
|
||
if (showPreview && !_skipFullImage) {
|
||
if (_hasFirstPad) {
|
||
const { w: epImgW, h: epImgH } = getEffectiveImageDims(_epBase.mode !== "pad_edge");
|
||
const totalW = epImgW + _epBase.left + _epBase.right, totalH = epImgH + _epBase.top + _epBase.bottom;
|
||
const cX = canvasEl.width * (_epBase.left / totalW), cY = canvasEl.height * (_epBase.top / totalH);
|
||
const cW = canvasEl.width * (epImgW / totalW), cH = canvasEl.height * (epImgH / totalH);
|
||
const rcW = Math.max(1, Math.round(cW)), rcH = Math.max(1, Math.round(cH));
|
||
// Render content (with replicate-rotate for pad_edge + rotation)
|
||
renderToTmp(rcW, rcH);
|
||
drawExtraPadFill(ctx, _epBase, 0, 0, canvasEl.width, canvasEl.height, cX, cY, cW, cH, _tmpCanvas);
|
||
drawPreviewRegion(ctx, 0, 0, canvasEl.width, canvasEl.height, cX, cY, cW, cH);
|
||
} else {
|
||
if (node._rotation !== 0) { ctx.fillStyle = getFillColor(); ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); }
|
||
const { pW, pH, pX, pY } = fitAspect(_rotCanvas.width / _rotCanvas.height, canvasEl.width, canvasEl.height);
|
||
ctx.drawImage(getRotatedPreview(), 0, 0, _rotCanvas.width, _rotCanvas.height, pX, pY, pW, pH);
|
||
}
|
||
} else if (!showPreview) {
|
||
ctx.fillStyle = "#383838"; ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
|
||
const divBy = getDivBy(), { w: lw0, h: lh0 } = getTgt();
|
||
Object.assign(ctx, { fillStyle: "#888", font: "14px sans-serif", textAlign: "center" });
|
||
ctx.fillText(`${roundDown(lw0, divBy) || canvasEl.width} × ${roundDown(lh0, divBy) || canvasEl.height}`,
|
||
canvasEl.width / 2, canvasEl.height / 2);
|
||
}
|
||
|
||
// Manual mode: visualize extra padding on the placeholder
|
||
if (!hasBboxes && !showPreview) {
|
||
const _ep = getExtraPadding();
|
||
if (hasExtraPad(_ep)) {
|
||
const cW0 = canvasEl.width, cH0 = canvasEl.height;
|
||
const { w: tw0, h: th0 } = getTgt();
|
||
const rawW = tw0 > 0 ? tw0 : cW0;
|
||
const rawH = th0 > 0 ? th0 : cH0;
|
||
const content = computeContentDims(rawW, rawH);
|
||
const contentW = content ? content.w : rawW;
|
||
const contentH = content ? content.h : rawH;
|
||
const totalW = contentW + _ep.left + _ep.right;
|
||
const totalH = contentH + _ep.top + _ep.bottom;
|
||
const { pW, pH, pX, pY } = fitAspect(totalW / totalH, cW0, cH0);
|
||
const cW = pW * (contentW / totalW), cH = pH * (contentH / totalH);
|
||
const cX = pX + pW * (_ep.left / totalW), cY = pY + pH * (_ep.top / totalH);
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||
ctx.fillRect(0, 0, cW0, cH0);
|
||
if (_ep.mode === "pad_color" || _ep.mode === "pad_crop_color") {
|
||
ctx.fillStyle = getFillColor();
|
||
} else {
|
||
ctx.fillStyle = "#555";
|
||
}
|
||
ctx.fillRect(pX, pY, pW, pH);
|
||
ctx.fillStyle = "#383838";
|
||
ctx.fillRect(cX, cY, cW, cH);
|
||
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
|
||
// Redraw dimension text on top
|
||
const divBy = getDivBy(), { w: lw0, h: lh0 } = getTgt();
|
||
Object.assign(ctx, { fillStyle: "#888", font: "14px sans-serif", textAlign: "center" });
|
||
ctx.fillText(`${roundDown(lw0, divBy) || cW0} × ${roundDown(lh0, divBy) || cH0}`, cX + cW / 2, cY + cH / 2);
|
||
}
|
||
}
|
||
|
||
// When no bboxes are drawn, show pad/crop preview for the full image (auto mode only)
|
||
if (!hasBboxes && node._previewEnabled) {
|
||
const cW0 = canvasEl.width, cH0 = canvasEl.height;
|
||
if (node._previewImg) {
|
||
const { w: tgtW, h: tgtH } = getTgt();
|
||
const kp = getKp();
|
||
// Multiplier mode: compute effective target from crop dims × multiplier
|
||
if (kp === "multiplier") {
|
||
const wmv = _getSubWidget("keep_proportion.width_mult")?.value || 1.0;
|
||
const hmv = _getSubWidget("keep_proportion.height_mult")?.value || 1.0;
|
||
if (wmv !== 1.0 || hmv !== 1.0) {
|
||
const nw = node._previewImg.naturalWidth;
|
||
const nh = node._previewImg.naturalHeight;
|
||
drawStretchOverlay(ctx, (nw * wmv) / (nh * hmv), 0, 0, cW0, cH0);
|
||
}
|
||
}
|
||
if ((tgtW > 0 || tgtH > 0) && (kp === "stretch" || isPadMode(kp) || kp === "crop")) {
|
||
const { w: imgW, h: imgH } = getEffectiveImageDims(kp !== "pad_edge");
|
||
const { rw, rh } = getEffectiveTargetDims(imgW, imgH);
|
||
|
||
if (kp === "stretch") {
|
||
drawStretchOverlay(ctx, rw / rh, 0, 0, cW0, cH0);
|
||
} else if (isPadMode(kp)) {
|
||
const epPad = getExtraPadding();
|
||
const hasEp = hasExtraPad(epPad);
|
||
// Full output = target. Content = target - extra padding.
|
||
const fullTgtW = tgtW > 0 ? tgtW : imgW;
|
||
const fullTgtH = tgtH > 0 ? tgtH : imgH;
|
||
const contentTgtW = hasEp ? Math.max(1, fullTgtW - epPad.left - epPad.right) : fullTgtW;
|
||
const contentTgtH = hasEp ? Math.max(1, fullTgtH - epPad.top - epPad.bottom) : fullTgtH;
|
||
// Fit the full output to the canvas
|
||
const { pW: outerW, pH: outerH, pX: outerX, pY: outerY } = fitAspect(fullTgtW / fullTgtH, cW0, cH0);
|
||
// Content area within the output (offset by padding)
|
||
const contentW = outerW * (contentTgtW / fullTgtW);
|
||
const contentH = outerH * (contentTgtH / fullTgtH);
|
||
const contentX = outerX + outerW * (epPad.left / fullTgtW);
|
||
const contentY = outerY + outerH * (epPad.top / fullTgtH);
|
||
// Image is sized to fit the content area, but positioned across the full output via pad_x/pad_y
|
||
const imgRatio = Math.min(contentTgtW / imgW, contentTgtH / imgH);
|
||
const scaledW = imgW * imgRatio, scaledH = imgH * imgRatio;
|
||
const pW = outerW, pH = outerH, pX = outerX, pY = outerY;
|
||
const cW = outerW * (scaledW / fullTgtW), cH = outerH * (scaledH / fullTgtH);
|
||
const { cX, cY } = computePadContentPos(outerX, outerY, outerW, outerH, cW, cH);
|
||
ctx.clearRect(0, 0, cW0, cH0);
|
||
// Fill outer area (extra padding region)
|
||
if (hasEp) {
|
||
ctx.fillStyle = getFillColor();
|
||
ctx.fillRect(outerX, outerY, outerW, outerH);
|
||
}
|
||
if (kp === "pad_edge") {
|
||
renderToTmp(Math.max(1, Math.round(cW)), Math.max(1, Math.round(cH)),
|
||
null, 0, 0, 0, 0, true);
|
||
ctx.drawImage(_tmpCanvas, 0, 0, _tmpCanvas.width, _tmpCanvas.height, cX, cY, cW, cH);
|
||
drawExtraPadFill(ctx, getEdgeEp(), pX, pY, pW, pH, cX, cY, cW, cH, _tmpCanvas);
|
||
} else {
|
||
ctx.fillStyle = getFillColor();
|
||
ctx.fillRect(pX, pY, pW, pH);
|
||
drawPreviewRegion(ctx, 0, 0, cW0, cH0, cX, cY, cW, cH);
|
||
}
|
||
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
|
||
if (hasEp) drawDashedBorder(ctx, contentX, contentY, contentW, contentH, "rgba(255, 200, 100, 0.6)");
|
||
} else if (kp === "crop") {
|
||
const ratio = Math.max(rw / imgW, rh / imgH);
|
||
const visW = rw / ratio, visH = rh / ratio;
|
||
const offX = (imgW - visW) / 2, offY = (imgH - visH) / 2;
|
||
const sc = Math.min(cW0 / imgW, cH0 / imgH);
|
||
const ox = (cW0 - imgW * sc) / 2, oy = (cH0 - imgH * sc) / 2;
|
||
const vx1 = ox + offX * sc, vy1 = oy + offY * sc;
|
||
const vw = visW * sc, vh = visH * sc;
|
||
dimSides(ctx, 0, 0, cW0, cH0, vx1, vy1, vw, vh);
|
||
drawDashedBorder(ctx, vx1, vy1, vw, vh, "rgba(255, 255, 100, 0.8)");
|
||
}
|
||
}
|
||
}
|
||
const _ep = getExtraPadding();
|
||
if (hasExtraPad(_ep) && node._previewImg && !_isFirst && !(isPadMode(_kpVal) && (_tgtW > 0 || _tgtH > 0))) {
|
||
const { w: rawImgW, h: rawImgH } = getEffectiveImageDims(_ep.mode !== "pad_edge");
|
||
const content = computeContentDims(rawImgW, rawImgH);
|
||
const epImgW = content ? content.w : rawImgW;
|
||
const epImgH = content ? content.h : rawImgH;
|
||
const totalW = epImgW + _ep.left + _ep.right, totalH = epImgH + _ep.top + _ep.bottom;
|
||
const { pW, pH, pX, pY } = fitAspect(totalW / totalH, cW0, cH0);
|
||
ctx.clearRect(0, 0, cW0, cH0);
|
||
const cW = pW * (epImgW / totalW), cH = pH * (epImgH / totalH);
|
||
const cX = pX + pW * (_ep.left / totalW), cY = pY + pH * (_ep.top / totalH);
|
||
// Draw padding fill, then image in content area (same approach as keep_proportion pad mode)
|
||
if (_ep.mode === "pad_color") {
|
||
ctx.fillStyle = getFillColor();
|
||
ctx.fillRect(pX, pY, pW, pH);
|
||
drawPreviewRegion(ctx, 0, 0, cW0, cH0, cX, cY, cW, cH);
|
||
} else {
|
||
// pad_edge: render content for edge fill source
|
||
const rcW2 = Math.max(1, Math.round(cW)), rcH2 = Math.max(1, Math.round(cH));
|
||
renderToTmp(rcW2, rcH2, null, 0, 0, 0, 0, _ep.isFirst);
|
||
drawExtraPadFill(ctx, _ep, pX, pY, pW, pH, cX, cY, cW, cH, _tmpCanvas);
|
||
drawPreviewRegion(ctx, 0, 0, cW0, cH0, cX, cY, cW, cH);
|
||
}
|
||
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
|
||
}
|
||
|
||
drawGrid(ctx);
|
||
drawRotationCross(ctx);
|
||
if (!node._drawing) updateOutputSlotLabels();
|
||
return;
|
||
}
|
||
|
||
if (node._activeIdx >= 0 && node._activeIdx < node._bboxes.length) {
|
||
drawBbox(ctx, node._bboxes[node._activeIdx], node._activeIdx, true);
|
||
}
|
||
for (let i = 0; i < node._bboxes.length; i++) {
|
||
if (i === node._activeIdx) continue;
|
||
drawBbox(ctx, node._bboxes[i], i, false);
|
||
}
|
||
|
||
drawGrid(ctx);
|
||
drawRotationCross(ctx);
|
||
if (!node._drawing) updateOutputSlotLabels();
|
||
}
|
||
|
||
let _lastSlotLabelKey = "";
|
||
function updateOutputSlotLabels() {
|
||
const activeBbox = (node._activeIdx >= 0 && node._activeIdx < node._bboxes.length)
|
||
? node._bboxes[node._activeIdx] : null;
|
||
let dims = activeBbox ? getOutputDims(activeBbox) : null;
|
||
if (!dims) {
|
||
const { w: tW, h: tH } = getTgt();
|
||
const imgW = node._previewImg ? node._previewImg.naturalWidth
|
||
: (tW > 0 ? tW : canvasEl.width);
|
||
const imgH = node._previewImg ? node._previewImg.naturalHeight
|
||
: (tH > 0 ? tH : canvasEl.height);
|
||
const computed = computeOutputDims(imgW, imgH);
|
||
if (computed && computed.w > 0 && computed.h > 0) {
|
||
dims = computed;
|
||
}
|
||
}
|
||
// Skip expensive DOM/canvas updates if nothing changed
|
||
const key = `${dims?.w || 0},${dims?.h || 0},${node._bboxes.length}`;
|
||
if (key === _lastSlotLabelKey) return;
|
||
_lastSlotLabelKey = key;
|
||
if (node.outputs) {
|
||
const hasBboxes = node._bboxes.length > 0;
|
||
for (let i = 0; i < node.outputs.length; i++) {
|
||
const out = node.outputs[i];
|
||
if (!out._origName) {
|
||
if (out.name?.includes("width") || out.label?.includes("width")) out._origName = "width";
|
||
else if (out.name?.includes("height") || out.label?.includes("height")) out._origName = "height";
|
||
else if (out.name === "bbox") out._origName = "bbox";
|
||
else if (out.name === "bbox_mask") out._origName = "bbox_mask";
|
||
}
|
||
if (out._origName === "width") {
|
||
out.label = dims ? `${dims.w} width` : "width";
|
||
} else if (out._origName === "height") {
|
||
out.label = dims ? `${dims.h} height` : "height";
|
||
}
|
||
// Identify output/output_mask slots
|
||
if (!out._origName) {
|
||
if (out.name === "cropped" || out.name === "output") out._origName = "output";
|
||
else if (out.name === "cropped_mask" || out.name === "output_mask") out._origName = "output_mask";
|
||
}
|
||
// Toggle list icon on output/output_mask
|
||
if (out._origName === "output" || out._origName === "output_mask") {
|
||
out.shape = hasBboxes && node._bboxes.length > 1 ? 6 : undefined;
|
||
}
|
||
// Visually hide bbox/bbox_mask slots when no bboxes, show with list icon when multiple
|
||
if (out._origName === "bbox" || out._origName === "bbox_mask") {
|
||
if (!hasBboxes) {
|
||
out.label = " ";
|
||
out.color_off = "transparent";
|
||
out.color_on = "transparent";
|
||
out.shape = 6;
|
||
out.dir = undefined;
|
||
} else {
|
||
out.label = out._origName;
|
||
out.color_off = undefined;
|
||
out.color_on = undefined;
|
||
out.shape = node._bboxes.length > 1 ? 6 : undefined;
|
||
}
|
||
}
|
||
}
|
||
node.setDirtyCanvas(true, true);
|
||
// Vue nodes mode: hide/show bbox slots via DOM since canvas properties don't apply
|
||
if (LiteGraph.vueNodesMode && node.id != null) {
|
||
const nodeEl = document.querySelector(`[data-node-id="${node.id}"]`);
|
||
if (nodeEl) {
|
||
const outputSlots = nodeEl.querySelectorAll(".lg-slot--output");
|
||
for (let i = 0; i < node.outputs.length; i++) {
|
||
const out = node.outputs[i];
|
||
if ((out._origName === "bbox" || out._origName === "bbox_mask") && outputSlots[i]) {
|
||
outputSlots[i].style.visibility = hasBboxes ? "" : "hidden";
|
||
// Update label text directly since Vue may not observe raw property changes
|
||
if (hasBboxes) {
|
||
const labelEl = outputSlots[i].querySelector("span");
|
||
if (labelEl) labelEl.textContent = out._origName;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function loadPreviewImage(src, preserveBbox) {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
img.onload = () => {
|
||
node._previewImg = img;
|
||
_rotCacheKey = "";
|
||
invalidateGridStops();
|
||
restoreCanvasToImage();
|
||
if (node._previewEnabled && !preserveBbox) {
|
||
node._bboxes = [];
|
||
node._activeIdx = -1;
|
||
}
|
||
updateBboxWidgets();
|
||
drawCanvas();
|
||
fitNodeToImage(preserveBbox);
|
||
};
|
||
img.onerror = () => {
|
||
console.warn("ImageTransformKJ: failed to load preview:", src);
|
||
};
|
||
img.src = src;
|
||
}
|
||
|
||
function loadPreviewFromTempFile(filename) {
|
||
if (!filename) return;
|
||
loadPreviewImage(`/view?filename=${encodeURIComponent(filename)}&type=temp`, true);
|
||
}
|
||
|
||
function captureVideoFrameToPreview(videoEl, preserveBbox) {
|
||
captureVideoFrame(videoEl, (canvas) => {
|
||
loadPreviewImage(canvas.toDataURL("image/webp", 0.9), preserveBbox);
|
||
});
|
||
}
|
||
|
||
function handleSourceChange(sources, preserveBbox) {
|
||
if (sources.length === 0) {
|
||
// Disconnected
|
||
node._previewImg = null;
|
||
delete node.properties.previewFile;
|
||
if (node._previewEnabled) {
|
||
node._bboxes = [];
|
||
node._activeIdx = -1;
|
||
updateBboxWidgets();
|
||
}
|
||
updateCanvasFromTargetDims();
|
||
drawCanvas();
|
||
return;
|
||
}
|
||
const src = sources[0];
|
||
if (src.isVideo && src.videoEl) {
|
||
captureVideoFrameToPreview(src.videoEl, preserveBbox);
|
||
} else if (src.url) {
|
||
loadPreviewImage(src.url, preserveBbox);
|
||
}
|
||
}
|
||
|
||
let _configuring = false, _sourceResolved = false;
|
||
watchImageInputs(node, "image", (sources) => {
|
||
_sourceResolved = true;
|
||
handleSourceChange(sources, _configuring);
|
||
});
|
||
|
||
chainCallback(this, "onResize", function () {
|
||
if (_resizing) return;
|
||
const availW = Math.max(100, node.size[0] - 30);
|
||
_resizing = true;
|
||
if (node._previewEnabled) {
|
||
// Auto mode: resize canvas pixel buffer to match node width
|
||
const ar = canvasEl.height / canvasEl.width;
|
||
const newW = Math.round(availW);
|
||
const newH = Math.round(availW * ar);
|
||
if (newW !== canvasEl.width || newH !== canvasEl.height) {
|
||
rescaleBboxesTo(newW, newH);
|
||
canvasEl.width = newW;
|
||
canvasEl.height = newH;
|
||
canvasEl.style.aspectRatio = `${newW} / ${newH}`;
|
||
node.properties.canvasSize = [newW, newH];
|
||
}
|
||
node._widgetHeight = newH + GRID_BAR_HEIGHT;
|
||
} else {
|
||
// Manual mode: keep pixel buffer fixed, CSS handles display scaling
|
||
const displayedH = Math.round(canvasEl.height * (availW / canvasEl.width));
|
||
node._widgetHeight = displayedH + GRID_BAR_HEIGHT;
|
||
}
|
||
drawCanvas();
|
||
_resizing = false;
|
||
});
|
||
|
||
chainCallback(this, "onConfigure", function () {
|
||
node._cropConfigured = true;
|
||
if (node.properties.gridSize !== undefined) {
|
||
gridSlider.value = node.properties.gridSize;
|
||
updateGridLabel();
|
||
}
|
||
if (node.properties.previewEnabled !== undefined) {
|
||
node._previewEnabled = node.properties.previewEnabled;
|
||
updatePreviewBtn();
|
||
}
|
||
restoreBboxesFromWidget();
|
||
if (node.properties.rotation !== undefined) node._rotation = node.properties.rotation;
|
||
if (node.properties.fillColor) {
|
||
colorInput.value = node.properties.fillColor;
|
||
colorSwatch.style.background = node.properties.fillColor;
|
||
}
|
||
if (node.properties.gridColor) {
|
||
gridColorInput.value = node.properties.gridColor;
|
||
gridColorSwatch.style.background = node.properties.gridColor;
|
||
}
|
||
|
||
if (node.properties.canvasSize) {
|
||
setCanvasSize(node.properties.canvasSize[0], node.properties.canvasSize[1]);
|
||
}
|
||
|
||
_configuring = true;
|
||
_sourceResolved = false;
|
||
setTimeout(() => {
|
||
if (node.properties.gridSize !== undefined) {
|
||
gridSlider.value = node.properties.gridSize;
|
||
updateGridLabel();
|
||
}
|
||
_configuring = false;
|
||
if (!_sourceResolved) {
|
||
if (node.properties.previewFile) {
|
||
loadPreviewFromTempFile(node.properties.previewFile);
|
||
} else {
|
||
updateCanvasFromTargetDims();
|
||
drawCanvas();
|
||
}
|
||
}
|
||
}, 200);
|
||
});
|
||
|
||
chainCallback(this, "onExecuted", function (message) {
|
||
const filename = message?.preview_filename?.[0];
|
||
if (filename) {
|
||
node.properties.previewFile = filename;
|
||
loadPreviewFromTempFile(filename);
|
||
}
|
||
});
|
||
|
||
// Initial draw
|
||
setCanvasSize(400, 300);
|
||
drawCanvas();
|
||
});
|
||
},
|
||
});
|