Initial Z-Image-Turbo region builder

This commit is contained in:
bbc-s
2026-06-10 15:34:20 +02:00
commit 226fabacb0
11 changed files with 1995 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
outputs/
input/
temp/
+47
View File
@@ -0,0 +1,47 @@
# ZIT-Ideogram
ComfyUI custom node that reuses the KJNodes Ideogram 4 visual box editor pattern for Z-Image-Turbo regional prompting.
## Node
`Z-Image-Turbo Region Builder KJ`
Outputs:
- `positive` / `negative`: Z-Image text conditioning with per-region mask metadata.
- `region_masks`: one mask per drawn box, shape `N,H,W`.
- `combined_mask`: max-composited mask for img2img/inpaint noise masks.
- `source_image`: pass-through source image, or a blank image for text-to-image.
- `preview`: editor preview with regions and mask tint.
- `regions_json`, `bboxes`, `width`, `height`.
## Installation
1. Put this folder in `ComfyUI/custom_nodes/ZIT-Ideogram`.
2. Restart ComfyUI.
3. Add node: `ZIT-Ideogram/Z-Image > Z-Image-Turbo Region Builder KJ`.
No extra Python packages are required.
## Usage
Use ComfyUI's built-in Z-Image-Turbo workflow as the base graph, then replace the normal text encoders with this node:
1. Connect Z-Image/Qwen `CLIP` to `clip`.
2. Connect `positive` and `negative` to the sampler.
3. For text-to-image, connect `width` and `height` to your latent/image size nodes.
4. For image-to-image regional editing, connect a source image to `image`, use `combined_mask` as the inpaint/noise mask, and sample at a denoise/strength suitable for the edit.
5. Draw boxes in the editor and set each region prompt, optional region negative prompt, strength, and feather.
The node does not call any Ideogram API and does not emit Ideogram caption JSON. It produces native ComfyUI conditioning and masks.
## Examples
Example region presets are in `examples/`:
- `text_to_image_regional_prompting.json`
- `portrait_hair_editing.json`
- `clothing_replacement.json`
- `background_only_modification.json`
Open a Z-Image-Turbo workflow first, add this node, then use the node's `Paste` button to import the example region JSON.
+13
View File
@@ -0,0 +1,13 @@
from .nodes.zimage_turbo_region_builder import ZImageTurboRegionBuilderKJ
NODE_CLASS_MAPPINGS = {
"ZImageTurboRegionBuilderKJ": ZImageTurboRegionBuilderKJ,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ZImageTurboRegionBuilderKJ": "Z-Image-Turbo Region Builder KJ",
}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -0,0 +1,36 @@
{
"global_prompt": "preserve the foreground subject exactly, keep pose, face, clothes, scale, and lighting consistent",
"global_negative_prompt": "changed subject, altered body, changed clothes, extra people, subject blur",
"regions": [
{
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 0.28,
"prompt": "soft golden sunset sky with subtle clouds",
"negative_prompt": "harsh shadows, storm, text",
"strength": 0.75,
"feather": 40
},
{
"x": 0.0,
"y": 0.28,
"w": 0.22,
"h": 0.72,
"prompt": "blurred park background with trees and warm bokeh",
"negative_prompt": "foreground person, extra limbs",
"strength": 0.75,
"feather": 36
},
{
"x": 0.78,
"y": 0.28,
"w": 0.22,
"h": 0.72,
"prompt": "blurred park background with trees and warm bokeh",
"negative_prompt": "foreground person, extra limbs",
"strength": 0.75,
"feather": 36
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"global_prompt": "preserve the person, pose, face, hands, lighting, camera angle, and background",
"global_negative_prompt": "changed identity, changed pose, damaged hands, distorted face, low quality",
"regions": [
{
"x": 0.27,
"y": 0.38,
"w": 0.46,
"h": 0.43,
"prompt": "tailored dark navy blazer over a clean white shirt, realistic fabric folds",
"negative_prompt": "old outfit, logos, torn fabric, extra buttons",
"strength": 0.9,
"feather": 28
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"global_prompt": "preserve the original portrait, face, expression, body, clothes, background, lighting, and composition",
"global_negative_prompt": "changed face, changed clothing, changed background, extra people, low quality",
"regions": [
{
"x": 0.33,
"y": 0.08,
"w": 0.34,
"h": 0.38,
"prompt": "long red curly hair, natural strands, realistic volume",
"negative_prompt": "hat, wig edge, changed face, dyed skin",
"strength": 0.85,
"feather": 32
}
]
}
@@ -0,0 +1,36 @@
{
"global_prompt": "wide cinematic illustration, clean composition, natural lighting",
"global_negative_prompt": "low quality, blurry, distorted anatomy, text artifacts",
"regions": [
{
"x": 0.04,
"y": 0.12,
"w": 0.28,
"h": 0.72,
"prompt": "left side: tall glass greenhouse filled with tropical plants",
"negative_prompt": "empty room, plain wall",
"strength": 1.0,
"feather": 24
},
{
"x": 0.36,
"y": 0.18,
"w": 0.28,
"h": 0.62,
"prompt": "center: wooden table with ceramic tea set and warm steam",
"negative_prompt": "plastic, clutter",
"strength": 1.0,
"feather": 18
},
{
"x": 0.68,
"y": 0.1,
"w": 0.28,
"h": 0.76,
"prompt": "right side: arched window overlooking a rainy city skyline",
"negative_prompt": "blank window, overexposed",
"strength": 1.0,
"feather": 24
}
]
}
Binary file not shown.
+302
View File
@@ -0,0 +1,302 @@
"""Z-Image-Turbo regional prompt builder.
This node is based on KJNodes' Ideogram 4 visual region editor, but emits native
ComfyUI/Z-Image conditioning and masks instead of Ideogram caption JSON.
"""
import json
import os
import node_helpers
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
from comfy_api.latest import io
_FONT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "FreeMono.ttf")
def _font(size):
try:
return ImageFont.truetype(_FONT_PATH, size)
except Exception:
try:
return ImageFont.load_default(size)
except Exception:
return ImageFont.load_default()
def _parse_json_list(s):
if s:
try:
v = json.loads(s)
if isinstance(v, list):
return v
except json.JSONDecodeError:
pass
return []
def _dumps(v):
return json.dumps(v, ensure_ascii=False, indent=2)
def _norm_box(box):
x = float(box.get("x", 0.0))
y = float(box.get("y", 0.0))
w = float(box.get("w", 0.0))
h = float(box.get("h", 0.0))
if w < 0:
x += w
w = -w
if h < 0:
y += h
h = -h
x = max(0.0, min(1.0, x))
y = max(0.0, min(1.0, y))
w = max(0.0, min(1.0 - x, w))
h = max(0.0, min(1.0 - y, h))
return x, y, w, h
def _encode_zimage(clip, prompt):
tokens = clip.tokenize(prompt or "")
return clip.encode_from_tokens_scheduled(tokens)
def _set_mask(conditioning, mask, strength, set_area_to_bounds=True):
return node_helpers.conditioning_set_values(
conditioning,
{
"mask": mask,
"set_area_to_bounds": bool(set_area_to_bounds),
"mask_strength": float(strength),
},
)
def _box_mask(width, height, box, default_feather, default_strength):
x, y, w, h = _norm_box(box)
x1 = max(0, min(width, round(x * width)))
y1 = max(0, min(height, round(y * height)))
x2 = max(0, min(width, round((x + w) * width)))
y2 = max(0, min(height, round((y + h) * height)))
mask = Image.new("L", (width, height), 0)
if x2 > x1 and y2 > y1:
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
feather = float(box.get("feather", default_feather) or 0.0)
if feather > 0:
mask = mask.filter(ImageFilter.GaussianBlur(radius=feather))
strength = float(box.get("strength", default_strength) or 0.0)
arr = np.asarray(mask, dtype=np.float32) / 255.0
arr *= max(0.0, min(10.0, strength))
return torch.from_numpy(arr).clamp(0.0, 1.0), {
"x": x1,
"y": y1,
"width": max(0, x2 - x1),
"height": max(0, y2 - y1),
}
def _wrap(draw, text, font, max_w):
lines = []
for para in (text or "").split("\n"):
line = ""
for word in para.split():
test = word if not line else line + " " + word
if line and draw.textlength(test, font=font) > max_w:
lines.append(line)
line = word
else:
line = test
if line:
lines.append(line)
return lines
def _render_preview(boxes, width, height, masks, bg=None, brightness=50):
if bg is not None:
iw, ih = bg.size
long_edge = max(iw, ih)
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
rw, rh = max(1, round(iw * scale)), max(1, round(ih * scale))
base = bg.convert("RGB").resize((rw, rh), Image.LANCZOS)
base = ImageEnhance.Brightness(base).enhance(max(0.0, min(1.0, brightness / 100.0)))
img = base.convert("RGBA")
else:
long_edge = max(width, height)
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
rw, rh = max(1, round(width * scale)), max(1, round(height * scale))
g = round(max(0, min(100, brightness)) / 100 * 128)
img = Image.new("RGBA", (rw, rh), (g, g, g, 255))
overlay = Image.new("RGBA", (rw, rh), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
font = _font(max(10, round(rh / 64)))
tag_font = _font(max(9, round(rh / 72)))
colors = [(70, 180, 230), (240, 170, 60), (120, 205, 120), (220, 100, 120), (180, 140, 255)]
for i, box in enumerate(boxes):
x, y, w, h = _norm_box(box)
x1, y1 = round(x * rw), round(y * rh)
x2, y2 = round((x + w) * rw), round((y + h) * rh)
color = colors[i % len(colors)]
draw.rectangle([x1, y1, x2, y2], outline=color + (255,), width=2)
draw.rectangle([x1, y1, x1 + 26, y1 + 16], fill=color + (230,))
draw.text((x1 + 4, y1 + 2), str(i + 1).zfill(2), fill=(0, 0, 0, 255), font=tag_font)
text = box.get("prompt") or box.get("desc") or ""
ty = y1 + 21
for line in _wrap(draw, text, font, max(12, x2 - x1 - 8)):
if ty > y2:
break
draw.text((x1 + 4, ty), line, fill=color + (255,), font=font)
ty += font.size + 2 if hasattr(font, "size") else 13
if masks:
combined = torch.stack(masks).max(dim=0).values
m = Image.fromarray((combined.detach().cpu().numpy() * 120).clip(0, 120).astype(np.uint8), "L")
m = m.resize((rw, rh), Image.BILINEAR)
tint = Image.new("RGBA", (rw, rh), (70, 180, 230, 0))
tint.putalpha(m)
overlay = Image.alpha_composite(tint, overlay)
img = Image.alpha_composite(img, overlay).convert("RGB")
arr = np.asarray(img, dtype=np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
class ZImageTurboRegionBuilderKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ZImageTurboRegionBuilderKJ",
display_name="Z-Image-Turbo Region Builder KJ",
category="ZIT-Ideogram/Z-Image",
search_aliases=["z-image", "zimage", "turbo", "regional prompt", "mask", "inpaint"],
is_experimental=True,
description="""
Visual region prompt builder for Z-Image-Turbo.
Draw boxes on the canvas, set a prompt, optional negative prompt, strength, and
feather per region. The node outputs Z-Image text conditionings with ComfyUI
masks, a batch of independent masks, a combined mask for img2img/inpainting, and
serialized region data for workflow save/load.
""",
inputs=[
io.Clip.Input("clip"),
io.Int.Input("width", default=1024, min=64, max=16384, step=16),
io.Int.Input("height", default=1024, min=64, max=16384, step=16),
io.String.Input("global_prompt", multiline=True, default="", dynamic_prompts=True),
io.String.Input("global_negative_prompt", multiline=True, default="", dynamic_prompts=True),
io.Combo.Input("mode", options=["text_to_image", "image_to_image_region_edit"], default="text_to_image"),
io.Float.Input("default_region_strength", default=1.0, min=0.0, max=10.0, step=0.01),
io.Float.Input("default_feather", default=16.0, min=0.0, max=512.0, step=1.0),
io.Image.Input("image", optional=True, tooltip="Optional source/reference image shown in the editor and passed through."),
io.String.Input("regions_data", default="", socketless=True, advanced=True),
io.Int.Input("bg_brightness", default=35, min=0, max=100, socketless=True, advanced=True),
],
outputs=[
io.Conditioning.Output(display_name="positive"),
io.Conditioning.Output(display_name="negative"),
io.Mask.Output(display_name="region_masks"),
io.Mask.Output(display_name="combined_mask"),
io.Image.Output(display_name="source_image"),
io.Image.Output(display_name="preview"),
io.String.Output(display_name="regions_json"),
io.BoundingBox.Output(display_name="bboxes"),
io.Int.Output(display_name="width"),
io.Int.Output(display_name="height"),
],
)
@classmethod
def execute(
cls,
clip,
width,
height,
global_prompt,
global_negative_prompt,
mode,
default_region_strength,
default_feather,
regions_data="",
image=None,
bg_brightness=35,
) -> io.NodeOutput:
boxes = [b for b in _parse_json_list(regions_data) if isinstance(b, dict)]
masks = []
bbox_dicts = []
region_records = []
positive = _encode_zimage(clip, global_prompt)
negative = _encode_zimage(clip, global_negative_prompt)
for box in boxes:
mask, bbox = _box_mask(width, height, box, default_feather, default_region_strength)
if bbox["width"] <= 0 or bbox["height"] <= 0:
continue
masks.append(mask)
bbox_dicts.append(bbox)
strength = float(box.get("strength", default_region_strength) or default_region_strength)
prompt = (box.get("prompt") or box.get("desc") or "").strip()
neg_prompt = (box.get("negative_prompt") or "").strip()
if prompt:
positive += _set_mask(_encode_zimage(clip, prompt), mask.unsqueeze(0), strength)
if neg_prompt:
negative += _set_mask(_encode_zimage(clip, neg_prompt), mask.unsqueeze(0), strength)
region_records.append(
{
"bbox": bbox,
"prompt": prompt,
"negative_prompt": neg_prompt,
"strength": strength,
"feather": float(box.get("feather", default_feather) or 0.0),
}
)
if masks:
region_masks = torch.stack(masks, dim=0)
combined_mask = region_masks.max(dim=0).values.unsqueeze(0)
else:
region_masks = torch.zeros((1, height, width), dtype=torch.float32)
combined_mask = torch.zeros((1, height, width), dtype=torch.float32)
source_image = image
if source_image is None:
source_image = torch.zeros((1, height, width, 3), dtype=torch.float32)
bg = None
if image is not None:
try:
bg = Image.fromarray((image[0].detach().cpu().numpy() * 255).clip(0, 255).astype(np.uint8))
except Exception:
bg = None
preview = _render_preview(boxes, width, height, masks, bg, bg_brightness)
bboxes_out = [bbox_dicts] if bbox_dicts else []
region_json = {
"mode": mode,
"global_prompt": global_prompt,
"global_negative_prompt": global_negative_prompt,
"regions": region_records,
}
return io.NodeOutput(
positive,
negative,
region_masks,
combined_mask,
source_image,
preview,
_dumps(region_json),
bboxes_out,
width,
height,
ui={"dims": [width, height]},
)
+271
View File
@@ -0,0 +1,271 @@
const { app } = window.comfyAPI.app;
// ─── General-purpose helpers shared across KJNodes JS ───
export function makeUUID() {
let dt = new Date().getTime()
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = ((dt + Math.random() * 16) % 16) | 0
dt = Math.floor(dt / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
return uuid
}
export function chainCallback(object, property, callback) {
if (object == undefined) {
console.error("Tried to add callback to non-existant object")
return;
}
if (property in object) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
export function hideWidgetForGood(node, widget, suffix = '') {
widget.origType = widget.type
widget.origComputeSize = widget.computeSize
widget.origSerializeValue = widget.serializeValue
widget.computeSize = () => [0, -4]
widget.type = "converted-widget" + suffix
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) {
hideWidgetForGood(node, w, ':' + widget.name)
}
}
}
// ─── Graph coordinate helpers ───
export function clientToCanvas(lgCanvas, clientX, clientY) {
const rect = lgCanvas.canvas.getBoundingClientRect();
return [
(clientX - rect.left) / lgCanvas.ds.scale - lgCanvas.ds.offset[0],
(clientY - rect.top) / lgCanvas.ds.scale - lgCanvas.ds.offset[1],
];
}
export function getNodeAtPoint(graph, cx, cy) {
for (let i = graph._nodes.length - 1; i >= 0; i--) {
if (graph._nodes[i].isPointInside(cx, cy)) return graph._nodes[i];
}
return null;
}
export function typesCompatible(a, b) {
if (a === "*" || b === "*") return true;
if (a === b) return true;
if (typeof a !== "string" || typeof b !== "string") return false;
if (a.toUpperCase() === b.toUpperCase()) return true;
// ComfyUI accepts union types like "STRING,INT" — match if any token overlaps
if (a.includes(",") || b.includes(",")) {
const aTokens = a.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
const bTokens = b.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
if (aTokens.includes("*") || bTokens.includes("*")) return true;
return aTokens.some(t => bTokens.includes(t));
}
return false;
}
// ─── Slot position helper ───
// Uses modern API (DOM-registered positions in Vue mode) with legacy fallback
export function getSlotPos(node, isInput, slotIdx) {
if (isInput && node.getInputPos) return node.getInputPos(slotIdx);
if (!isInput && node.getOutputPos) return node.getOutputPos(slotIdx);
const out = [0, 0];
node.getConnectionPos(isInput, slotIdx, out);
return out;
}
// ─── Middle-click pan passthrough for DOM widgets ───
// Allows panning the LiteGraph canvas via middle-click drag on any DOM element
export function addMiddleClickPan(element) {
const onMouseDown = (e) => {
if (e.button !== 1) return;
e.preventDefault();
const ds = app.canvas?.ds;
if (!ds) return;
const startX = e.clientX, startY = e.clientY;
const startOffsetX = ds.offset[0], startOffsetY = ds.offset[1];
const onMove = (me) => {
ds.offset[0] = startOffsetX + (me.clientX - startX);
ds.offset[1] = startOffsetY + (me.clientY - startY);
app.canvas.setDirty(true, true);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
element.addEventListener('mousedown', onMouseDown);
return () => element.removeEventListener('mousedown', onMouseDown);
}
// ─── Resolve preview image/video from a connected source node ───
// Walks the graph link to find what's connected to node.inputs[inputSlot]
// and returns { url, isVideo, videoEl } or null
export function resolveSourcePreview(node, inputSlot) {
if (!node.graph) return null;
const input = node.inputs?.[inputSlot];
if (!input || input.link == null) return null;
const link = node.graph.links?.get(input.link);
if (!link) return null;
const srcNode = node.graph.getNodeById(link.origin_id);
if (!srcNode) return null;
// Check for VHS-style video preview widget
const vpWidget = srcNode.widgets?.find((w) => w.name === "videopreview");
if (vpWidget?.videoEl?.src) {
return { isVideo: true, videoEl: vpWidget.videoEl };
}
// Look for a LoadImage "image" widget or LoadVideo "video" widget
const w = srcNode.widgets?.find((w) => w.name === "image" || w.name === "video");
if (w?.value) {
let subfolder = "", fname = w.value;
const lastSlash = fname.lastIndexOf("/");
if (lastSlash >= 0) { subfolder = fname.substring(0, lastSlash); fname = fname.substring(lastSlash + 1); }
const isVideo = w.name === "video";
const url = `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(subfolder)}`;
return { url, isVideo };
}
// Fallback: check node.imgs (set after execution)
if (srcNode.imgs?.length > 0 && srcNode.imgs[0].src) {
return { url: srcNode.imgs[0].src, isVideo: false };
}
return null;
}
// ─── Source input watcher ───
// Watches a named IMAGE input for connection changes and source widget changes.
// Calls onChange(sources) when connections change or the source node's image/video widget changes.
// sources: array of { url, isVideo, videoEl } per connected IMAGE input matching inputName (or all if inputName is null).
// Returns a cleanup function.
export function watchImageInputs(node, inputName, onChange) {
let watchedWidgets = [];
function unwatchWidgets() {
for (const { widget, origCb } of watchedWidgets) widget.callback = origCb;
watchedWidgets = [];
}
function resolve() {
if (!node.inputs) return [];
const slots = inputName
? node.inputs.map((inp, i) => inp.name === inputName ? i : -1).filter(i => i >= 0)
: node.inputs.map((inp, i) => inp.type === "IMAGE" ? i : -1).filter(i => i >= 0);
return slots.map(i => resolveSourcePreview(node, i)).filter(s => s !== null);
}
function watch() {
unwatchWidgets();
if (!node.inputs || !node.graph) return;
const slots = inputName
? node.inputs.map((inp, i) => inp.name === inputName ? i : -1).filter(i => i >= 0)
: node.inputs.map((inp, i) => inp.type === "IMAGE" ? i : -1).filter(i => i >= 0);
for (const slotIdx of slots) {
const input = node.inputs[slotIdx];
if (!input || input.link == null) continue;
const link = node.graph.links?.get(input.link);
if (!link) continue;
const srcNode = node.graph.getNodeById(link.origin_id);
if (!srcNode) continue;
const w = srcNode.widgets?.find(w => w.name === "image" || w.name === "video");
if (!w) continue;
const origCb = w.callback;
w.callback = function (...args) {
if (origCb) origCb.apply(this, args);
setTimeout(() => onChange(resolve()), 100);
};
watchedWidgets.push({ widget: w, origCb });
}
}
chainCallback(node, "onConnectionsChange", function (type) {
// Only react to input connection changes, not output
if (type != null && type !== 1) return;
setTimeout(() => {
watch();
onChange(resolve());
}, 100);
});
chainCallback(node, "onRemoved", unwatchWidgets);
return unwatchWidgets;
}
// ─── Video frame capture ───
// Draws the current frame of a video element to a fresh canvas at native resolution.
// If the video isn't decoded yet (readyState < 2), waits once for "loadeddata" before capturing.
// Calls callback(canvas) with the resulting canvas. Caller decides whether to use it directly
// (drawImage accepts canvas) or convert via toDataURL.
export function captureVideoFrame(videoEl, callback) {
const capture = () => {
if (!videoEl.videoWidth || !videoEl.videoHeight) return;
const c = document.createElement("canvas");
c.width = videoEl.videoWidth;
c.height = videoEl.videoHeight;
c.getContext("2d").drawImage(videoEl, 0, 0);
callback(c);
};
if (videoEl.readyState >= 2) {
capture();
} else {
const onReady = () => { videoEl.removeEventListener("loadeddata", onReady); capture(); };
videoEl.addEventListener("loadeddata", onReady);
}
}
// ─── Bounding box hit test ───
// Tests whether (mx, my) hits a corner handle or the interior of a rect defined by (x1, y1)–(x2, y2).
// Returns "resize-tl", "resize-tr", "resize-bl", "resize-br", "resize-t", "resize-b", "resize-l", "resize-r", "move", or null.
export function rectHitTest(mx, my, x1, y1, x2, y2, radius) {
const hit = (cx, cy) => Math.abs(mx - cx) < radius && Math.abs(my - cy) < radius;
// Corners first (higher priority)
if (hit(x1, y1)) return "resize-tl";
if (hit(x2, y1)) return "resize-tr";
if (hit(x1, y2)) return "resize-bl";
if (hit(x2, y2)) return "resize-br";
// Edges
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < radius) return "resize-t";
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < radius) return "resize-b";
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < radius) return "resize-l";
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < radius) return "resize-r";
// Interior
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return "move";
return null;
}
// Returns the appropriate CSS cursor for a bbox hit mode string.
export function cursorForBboxMode(mode) {
if (mode === "move") return "move";
if (mode === "resize-tl" || mode === "resize-br") return "nwse-resize";
if (mode === "resize-tr" || mode === "resize-bl") return "nesw-resize";
if (mode === "resize-t" || mode === "resize-b") return "ns-resize";
if (mode === "resize-l" || mode === "resize-r") return "ew-resize";
return null;
}
// ─── Wheel zoom passthrough for DOM widgets ───
// Re-dispatches wheel events to the LiteGraph canvas for zoom
export function addWheelPassthrough(element) {
element.addEventListener("wheel", (e) => {
const gc = document.getElementById("graph-canvas");
if (gc) {
gc.dispatchEvent(new WheelEvent(e.type, e));
e.preventDefault();
}
}, { passive: false });
}
File diff suppressed because it is too large Load Diff