Files
2026-05-28 09:52:43 +02:00

549 lines
16 KiB
JavaScript

import { app } from "../../../scripts/app.js";
const TARGET_NODES = new Set([
"NukunRegionalRectMasks",
"NukunNativeRegionalRectConditioning",
"NukunDenseDiffusionRectApply",
]);
const STEP = 0.05;
const MIN_SIZE = 0.05;
const EDITOR_HEIGHT = 260;
const COLORS = [
{ name: "1", stroke: "#22c55e", fill: "rgba(34, 197, 94, 0.34)" },
{ name: "2", stroke: "#3b82f6", fill: "rgba(59, 130, 246, 0.34)" },
{ name: "3", stroke: "#ef4444", fill: "rgba(239, 68, 68, 0.34)" },
];
let styleInjected = false;
function injectStyle() {
if (styleInjected) {
return;
}
styleInjected = true;
const style = document.createElement("style");
style.textContent = `
.nukun-rect-editor {
box-sizing: border-box;
width: 100%;
height: ${EDITOR_HEIGHT}px;
padding: 8px 10px 10px;
color: #d8d8d8;
font: 12px sans-serif;
user-select: none;
}
.nukun-rect-editor * {
box-sizing: border-box;
}
.nukun-rect-editor-toolbar {
display: flex;
align-items: center;
gap: 6px;
height: 24px;
margin-bottom: 6px;
}
.nukun-rect-editor-swatch {
width: 24px;
height: 20px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
color: white;
cursor: pointer;
line-height: 18px;
padding: 0;
font-size: 11px;
font-weight: 700;
}
.nukun-rect-editor-swatch.active {
border-color: white;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7);
}
.nukun-rect-editor-readout {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
}
.nukun-rect-editor-canvas {
display: block;
width: 100%;
height: 210px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 4px;
background: #191919;
cursor: crosshair;
}
`;
document.head.appendChild(style);
}
function widget(node, name) {
return node.widgets?.find((candidate) => candidate?.name === name);
}
function readNumber(node, name, fallback) {
const value = Number(widget(node, name)?.value);
return Number.isFinite(value) ? value : fallback;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function snap(value) {
return Number((Math.round(value / STEP) * STEP).toFixed(2));
}
function activeCount(node) {
return readNumber(node, "region_count", 3) >= 3 ? 3 : 2;
}
function readRect(node, index) {
return {
x: clamp(readNumber(node, `x_${index + 1}`, 0), 0, 1),
y: clamp(readNumber(node, `y_${index + 1}`, 0), 0, 1),
w: clamp(readNumber(node, `w_${index + 1}`, MIN_SIZE), 0, 1),
h: clamp(readNumber(node, `h_${index + 1}`, MIN_SIZE), 0, 1),
};
}
function fitRect(rect, doSnap) {
let x = doSnap ? snap(rect.x) : rect.x;
let y = doSnap ? snap(rect.y) : rect.y;
let w = doSnap ? snap(rect.w) : rect.w;
let h = doSnap ? snap(rect.h) : rect.h;
w = clamp(w, MIN_SIZE, 1);
h = clamp(h, MIN_SIZE, 1);
x = clamp(x, 0, 1 - MIN_SIZE);
y = clamp(y, 0, 1 - MIN_SIZE);
w = clamp(w, MIN_SIZE, 1 - x);
h = clamp(h, MIN_SIZE, 1 - y);
return { x, y, w, h };
}
function writeWidget(node, name, value) {
const target = widget(node, name);
if (!target) {
return;
}
target.value = value;
target.callback?.call(target, value, app.canvas, node);
}
function writeRect(node, index, rect) {
const fitted = fitRect(rect, true);
writeWidget(node, `x_${index + 1}`, fitted.x);
writeWidget(node, `y_${index + 1}`, fitted.y);
writeWidget(node, `w_${index + 1}`, fitted.w);
writeWidget(node, `h_${index + 1}`, fitted.h);
app.graph?.setDirtyCanvas?.(true, true);
}
function canvasArea(node, canvas) {
const width = Math.max(1, readNumber(node, "width", 1024));
const height = Math.max(1, readNumber(node, "height", 1024));
const cw = canvas.clientWidth || 320;
const ch = canvas.clientHeight || 210;
const margin = 12;
const scale = Math.min((cw - margin * 2) / width, (ch - margin * 2) / height);
const drawW = Math.max(40, width * scale);
const drawH = Math.max(40, height * scale);
return {
x: (cw - drawW) / 2,
y: (ch - drawH) / 2,
w: drawW,
h: drawH,
};
}
function toPoint(event, node, canvas) {
const rect = canvas.getBoundingClientRect();
const area = canvasArea(node, canvas);
return {
x: clamp((event.clientX - rect.left - area.x) / area.w, 0, 1),
y: clamp((event.clientY - rect.top - area.y) / area.h, 0, 1),
};
}
function insideArea(event, node, canvas) {
const rect = canvas.getBoundingClientRect();
const area = canvasArea(node, canvas);
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return x >= area.x && x <= area.x + area.w && y >= area.y && y <= area.y + area.h;
}
function handleAt(point, rect, area) {
const edgePx = 12;
const edgeX = Math.max(0.025, edgePx / area.w);
const edgeY = Math.max(0.025, edgePx / area.h);
const left = rect.x;
const top = rect.y;
const right = rect.x + rect.w;
const bottom = rect.y + rect.h;
const corners = [
{ mode: "nw", x: left, y: top },
{ mode: "ne", x: right, y: top },
{ mode: "sw", x: left, y: bottom },
{ mode: "se", x: right, y: bottom },
];
const cornerHits = corners
.map((corner) => {
const dx = Math.abs((point.x - corner.x) * area.w);
const dy = Math.abs((point.y - corner.y) * area.h);
return { ...corner, distance: dx * dx + dy * dy, hit: dx <= edgePx && dy <= edgePx };
})
.filter((corner) => corner.hit)
.sort((a, b) => a.distance - b.distance);
if (cornerHits.length > 0) {
return cornerHits[0].mode;
}
const west = Math.abs(point.x - left) <= edgeX && point.y >= top - edgeY && point.y <= bottom + edgeY;
const east = Math.abs(point.x - right) <= edgeX && point.y >= top - edgeY && point.y <= bottom + edgeY;
const north = Math.abs(point.y - top) <= edgeY && point.x >= left - edgeX && point.x <= right + edgeX;
const south = Math.abs(point.y - bottom) <= edgeY && point.x >= left - edgeX && point.x <= right + edgeX;
if (north) return "n";
if (south) return "s";
if (west) return "w";
if (east) return "e";
const inside =
point.x >= left &&
point.x <= right &&
point.y >= top &&
point.y <= bottom;
return inside ? "move" : null;
}
function resizeRect(startRect, startPoint, point, handle) {
let left = startRect.x;
let top = startRect.y;
let right = startRect.x + startRect.w;
let bottom = startRect.y + startRect.h;
if (handle.includes("w")) left = point.x;
if (handle.includes("e")) right = point.x;
if (handle.includes("n")) top = point.y;
if (handle.includes("s")) bottom = point.y;
if (right < left) {
[left, right] = [right, left];
}
if (bottom < top) {
[top, bottom] = [bottom, top];
}
if (right - left < MIN_SIZE) {
if (handle.includes("w")) left = right - MIN_SIZE;
else right = left + MIN_SIZE;
}
if (bottom - top < MIN_SIZE) {
if (handle.includes("n")) top = bottom - MIN_SIZE;
else bottom = top + MIN_SIZE;
}
return fitRect({ x: left, y: top, w: right - left, h: bottom - top }, true);
}
function drawRect(ctx, area, rect, color, selected) {
const x = area.x + rect.x * area.w;
const y = area.y + rect.y * area.h;
const w = rect.w * area.w;
const h = rect.h * area.h;
ctx.fillStyle = color.fill;
ctx.strokeStyle = color.stroke;
ctx.lineWidth = selected ? 3 : 2;
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = color.stroke;
ctx.font = "bold 12px sans-serif";
ctx.fillText(color.name, x + 5, y + 15);
if (selected) {
const s = 9;
const handles = [
[x, y],
[x + w / 2, y],
[x + w, y],
[x, y + h / 2],
[x + w, y + h / 2],
[x, y + h],
[x + w / 2, y + h],
[x + w, y + h],
];
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = "#111111";
ctx.lineWidth = 1;
for (const [hx, hy] of handles) {
ctx.fillRect(hx - s / 2, hy - s / 2, s, s);
ctx.strokeRect(hx - s / 2, hy - s / 2, s, s);
}
}
}
function render(editor) {
const { node, canvas, readout, buttons } = editor;
const dpr = window.devicePixelRatio || 1;
const cssW = Math.max(1, canvas.clientWidth || 320);
const cssH = Math.max(1, canvas.clientHeight || 210);
if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) {
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
}
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const area = canvasArea(node, canvas);
ctx.fillStyle = "#151515";
ctx.fillRect(0, 0, cssW, cssH);
ctx.fillStyle = "#202020";
ctx.fillRect(area.x, area.y, area.w, area.h);
ctx.strokeStyle = "rgba(255, 255, 255, 0.24)";
ctx.lineWidth = 1;
ctx.strokeRect(area.x, area.y, area.w, area.h);
ctx.beginPath();
for (let step = 0; step <= 20; step += 1) {
const gx = area.x + (step / 20) * area.w;
const gy = area.y + (step / 20) * area.h;
ctx.moveTo(gx, area.y);
ctx.lineTo(gx, area.y + area.h);
ctx.moveTo(area.x, gy);
ctx.lineTo(area.x + area.w, gy);
}
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.stroke();
const count = activeCount(node);
editor.active = Math.min(editor.active, count - 1);
for (let index = 0; index < 3; index += 1) {
buttons[index].hidden = index >= count;
buttons[index].classList.toggle("active", index === editor.active);
}
for (let index = 0; index < count; index += 1) {
if (index !== editor.active) {
drawRect(ctx, area, readRect(node, index), COLORS[index], false);
}
}
drawRect(ctx, area, readRect(node, editor.active), COLORS[editor.active], true);
const rect = readRect(node, editor.active);
readout.textContent = `R${editor.active + 1} x ${rect.x.toFixed(2)} y ${rect.y.toFixed(2)} w ${rect.w.toFixed(2)} h ${rect.h.toFixed(2)}`;
}
function cursorFor(handle) {
return {
n: "ns-resize",
s: "ns-resize",
e: "ew-resize",
w: "ew-resize",
ne: "nesw-resize",
sw: "nesw-resize",
nw: "nwse-resize",
se: "nwse-resize",
move: "move",
}[handle] || "crosshair";
}
function createEditor(node) {
injectStyle();
const container = document.createElement("div");
container.className = "nukun-rect-editor";
const toolbar = document.createElement("div");
toolbar.className = "nukun-rect-editor-toolbar";
const readout = document.createElement("div");
readout.className = "nukun-rect-editor-readout";
const buttons = COLORS.map((color, index) => {
const button = document.createElement("button");
button.className = "nukun-rect-editor-swatch";
button.type = "button";
button.textContent = color.name;
button.title = `Region ${index + 1}`;
button.style.background = color.stroke;
button.addEventListener("pointerdown", (event) => {
event.preventDefault();
editor.active = index;
render(editor);
});
toolbar.appendChild(button);
return button;
});
toolbar.appendChild(readout);
const canvas = document.createElement("canvas");
canvas.className = "nukun-rect-editor-canvas";
container.appendChild(toolbar);
container.appendChild(canvas);
const editor = {
node,
canvas,
readout,
buttons,
active: 0,
drag: null,
};
canvas.addEventListener("pointerdown", (event) => {
if (!insideArea(event, node, canvas)) {
return;
}
event.preventDefault();
canvas.setPointerCapture(event.pointerId);
const point = toPoint(event, node, canvas);
const area = canvasArea(node, canvas);
const handle = handleAt(point, readRect(node, editor.active), area);
if (handle) {
editor.drag = {
mode: handle,
startPoint: point,
startRect: readRect(node, editor.active),
};
} else {
editor.drag = {
mode: "draw",
startPoint: point,
startRect: { x: point.x, y: point.y, w: MIN_SIZE, h: MIN_SIZE },
};
writeRect(node, editor.active, editor.drag.startRect);
}
render(editor);
});
canvas.addEventListener("pointermove", (event) => {
const point = toPoint(event, node, canvas);
if (!editor.drag) {
const area = canvasArea(node, canvas);
const handle = handleAt(point, readRect(node, editor.active), area);
canvas.style.cursor = cursorFor(handle);
return;
}
event.preventDefault();
const { mode, startPoint, startRect } = editor.drag;
let nextRect;
if (mode === "move") {
nextRect = fitRect({
x: startRect.x + point.x - startPoint.x,
y: startRect.y + point.y - startPoint.y,
w: startRect.w,
h: startRect.h,
}, true);
} else if (mode === "draw") {
nextRect = fitRect({
x: Math.min(startPoint.x, point.x),
y: Math.min(startPoint.y, point.y),
w: Math.abs(point.x - startPoint.x),
h: Math.abs(point.y - startPoint.y),
}, true);
} else {
nextRect = resizeRect(startRect, startPoint, point, mode);
}
writeRect(node, editor.active, nextRect);
render(editor);
});
const finishDrag = (event) => {
if (editor.drag) {
event.preventDefault();
editor.drag = null;
render(editor);
}
};
canvas.addEventListener("pointerup", finishDrag);
canvas.addEventListener("pointercancel", finishDrag);
canvas.addEventListener("lostpointercapture", () => {
editor.drag = null;
render(editor);
});
const hookNames = ["region_count", "width", "height"];
for (let index = 1; index <= 3; index += 1) {
hookNames.push(`x_${index}`, `y_${index}`, `w_${index}`, `h_${index}`);
}
for (const name of hookNames) {
const target = widget(node, name);
if (!target) {
continue;
}
const previous = target.callback;
target.callback = function (value, ...args) {
const result = previous?.call(this, value, ...args);
requestAnimationFrame(() => render(editor));
return result;
};
}
const domWidget = node.addDOMWidget?.("rect_editor", "nukun_rect_editor", container, {
getValue: () => "",
setValue: () => {},
});
if (domWidget) {
domWidget.serialize = false;
domWidget.computeSize = () => [320, EDITOR_HEIGHT];
domWidget.computeLayoutSize = () => ({
minWidth: 300,
minHeight: EDITOR_HEIGHT,
maxHeight: EDITOR_HEIGHT,
});
}
requestAnimationFrame(() => {
render(editor);
const size = node.computeSize?.();
if (size) {
node.setSize?.([Math.max(node.size[0], 360), Math.max(node.size[1], size[1])]);
}
});
}
app.registerExtension({
name: "Nukun.RectEditor",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!TARGET_NODES.has(nodeData.name)) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.serialize_widgets = true;
createEditor(this);
return result;
};
},
});