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; }; }, });