Files
ausboss-ComfyUI-AusBoss/js/shared/layout_boxes.mjs
ausboss 8d94da719e Layout Boxes: boxes can be turned, to replace words at a slant
A round knob above the chosen box turns it (Shift: 5 degree steps); a Turn
field under the canvas types the angle and a double-click levels it. The
mask follows the turned box exactly, bboxes and elements keep the straight
box around it, and a turned text box adds how it is tilted to its
description. A box that is not turned saves and outputs exactly as before.
2026-10-19 19:00:42 -05:00

714 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Layout Boxes: the boxes' geometry and their saved form. No DOM and no
// ComfyUI imports, so node:test covers it (tests/layout_boxes.test.mjs); the
// canvas itself is js/layout_boxes/index.js.
//
// A box is kept as fractions of the canvas: { x, y, w, h, type, text, desc,
// palette }, x and y its top-left corner, w and h its size. A gesture works
// in canvas pixels, lands on whole pixels, and turns back into fractions by
// one division per number (pixels / side). The backend reads pixel boxes the
// same way, so a box drawn here gives the same outputs on either node.
//
// A turned box also has `angle`: degrees, clockwise on the screen, -90 to 90,
// about its centre. A level box has no `angle` at all, so its saved form is
// what it always was. The turn is made in canvas pixels, so a turned box is a
// true rectangle on a canvas of any shape (nodes/_layout_boxes_helpers.py
// turns it the same way).
export const DIM_MIN = 64;
export const DIM_MAX = 16384;
export const DIM_STEP = 16;
export const DEFAULT_SIZE = 1024;
// A prompt element carries up to five colors.
export const MAX_COLORS = 5;
// Enough for any layout; it stops a held Ctrl + D from filling the canvas.
export const MAX_BOXES = 64;
// The smallest box side with the grid off, in canvas pixels.
export const FREE_MIN_PX = 8;
// Grid cells are 32 px, or bigger on a big canvas so a side has at most 64.
export const GRID_MIN_CELL = 32;
export const GRID_MAX_CELLS = 64;
// An arrow key moves the chosen box this far, in canvas pixels; with Shift,
// the bigger step.
export const NUDGE_PX = 1;
export const NUDGE_SHIFT_PX = 10;
export const EMPTY_LAYOUT = '{"v": 1, "boxes": []}';
export const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
// Boxes without a color of their own (the backend's preview uses the same).
export const PLAIN_BOX = "#5fc9c0";
// How far a box can be turned, either way, in degrees.
export const ANGLE_LIMIT = 90;
// Shift while turning lands on steps of this many degrees.
export const ANGLE_SNAP = 5;
// A text box turned at least this far tells the model its slant.
export const TILT_FROM = 2;
const clamp = (value, lo, hi) => Math.max(lo, Math.min(hi, value));
export function finiteNumber(value) {
if (typeof value === "boolean" || value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
// ---------- turning ----------
// A box's turn as it is kept: degrees, clockwise on the screen, -90 to 90. A
// turn past 90 is the same rectangle as one 180 degrees back, so it is read
// that way. Anything that is not a number is 0, a level box.
export function readAngle(value) {
let number = finiteNumber(value);
if (number === null) return 0;
// Only a turn out of range is worked out again: the sums would add noise
// (25.2 would come back as 25.200000000000045).
if (Math.abs(number) > ANGLE_LIMIT) {
number = ((((number + 180) % 360) + 360) % 360) - 180;
if (number > ANGLE_LIMIT) number -= 180;
else if (number < -ANGLE_LIMIT) number += 180;
}
return number + 0;
}
export const angleOf = (box) => readAngle(box?.angle);
// A turn as the tag and the readout say it: "25°", "-7.5°".
export function angleLabel(angle) {
const tenths = Math.round(readAngle(angle) * 10) / 10 + 0;
return `${Number.isInteger(tenths) ? tenths : tenths.toFixed(1)}°`;
}
// What a turned text box adds to its description for the model (the backend
// adds it, nodes/_layout_boxes_helpers.py tilt_phrase; the same words here
// for the editor to show): "tilted about 25 degrees, running downhill to the
// right" for a box turned clockwise, "uphill" the other way, nothing for a
// turn under TILT_FROM degrees.
export function tiltPhrase(angle) {
const turn = readAngle(angle);
if (Math.abs(turn) < TILT_FROM) return "";
return `tilted about ${Math.floor(Math.abs(turn) + 0.5)} degrees, running ${turn > 0 ? "downhill" : "uphill"} to the right`;
}
// [cos, sin] of a turn, exact at a quarter turn.
function turnOf(angle) {
const turn = readAngle(angle);
if (Math.abs(turn) === ANGLE_LIMIT) return [0, Math.sign(turn)];
const radians = (turn * Math.PI) / 180;
return [Math.cos(radians), Math.sin(radians)];
}
// The point (u along the box's own top edge, v down its own left edge) from
// the centre (cx, cy), on the canvas, for a box turned by `angle`.
export function turnPoint(cx, cy, u, v, angle) {
const [cos, sin] = turnOf(angle);
return [cx + u * cos - v * sin, cy + u * sin + v * cos];
}
// A box's corners in canvas pixels: its top-left, top-right, bottom-right and
// bottom-left corner as drawn, turned about its centre.
export function boxCorners(box, width, height) {
const rect = boxPixels(box, width, height);
const cx = (rect.x0 + rect.x1) / 2;
const cy = (rect.y0 + rect.y1) / 2;
const hw = Math.abs(rect.x1 - rect.x0) / 2;
const hh = Math.abs(rect.y1 - rect.y0) / 2;
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([u, v]) => turnPoint(cx, cy, u, v, box?.angle));
}
// The straight box around a box, turned or not, in canvas pixels.
export function turnedBounds(box, width, height) {
const corners = boxCorners(box, width, height);
const xs = corners.map(([x]) => x);
const ys = corners.map(([, y]) => y);
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) };
}
// How far the pointer went round a centre from one of its angles to the next
// (radians), the short way: crossing the line left of the centre is a small
// step, not a whole turn.
export function turnStep(from, to) {
let step = to - from;
while (step > Math.PI) step -= 2 * Math.PI;
while (step < -Math.PI) step += 2 * Math.PI;
return step;
}
// The angle a turn gives: where it started plus how far the pointer went
// round (degrees), held to -90..90, on ANGLE_SNAP steps with Shift (`snap`),
// else on tenths of a degree.
export function turnedAngle(start, turned, { snap = false } = {}) {
const raw = clamp(readAngle(start) + (finiteNumber(turned) ?? 0), -ANGLE_LIMIT, ANGLE_LIMIT);
const landed = snap ? Math.round(raw / ANGLE_SNAP) * ANGLE_SNAP : Math.round(raw * 10) / 10;
return clamp(landed, -ANGLE_LIMIT, ANGLE_LIMIT) + 0;
}
// A turned box moved by (dx, dy) canvas pixels, whole pixels at a time. Its
// centre stays on the canvas and its corners may reach past the edges, so it
// can cover words that run to the edge of a picture. The grid steers level
// boxes only.
export function movedTurnedRect(start, dx, dy, width, height) {
const w = start.x1 - start.x0;
const h = start.y1 - start.y0;
const cx = clamp((start.x0 + start.x1) / 2 + Math.round(dx), 0, width);
const cy = clamp((start.y0 + start.y1) / 2 + Math.round(dy), 0, height);
return { x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2 };
}
// A handle of a turned box dragged by (dx, dy) canvas pixels. The drag is
// read along the box's own sides, the side (or corner) across from the
// handle stays where it is, and the sides keep whole pixels, at least
// FREE_MIN_PX and at most the canvas's diagonal. The centre stays on the
// canvas, as when the box is moved.
export function resizedTurnedRect(start, handle, dx, dy, angle, width, height) {
const [cos, sin] = turnOf(angle);
const du = dx * cos + dy * sin;
const dv = -dx * sin + dy * cos;
const longest = Math.ceil(Math.hypot(width, height));
const side = (value) => clamp(Math.round(value), FREE_MIN_PX, Math.max(FREE_MIN_PX, longest));
const hw = (start.x1 - start.x0) / 2;
const hh = (start.y1 - start.y0) / 2;
let [left, right, top, bottom] = [-hw, hw, -hh, hh];
if (handle.includes("e")) right = left + side(right + du - left);
if (handle.includes("w")) left = right - side(right - left - du);
if (handle.includes("s")) bottom = top + side(bottom + dv - top);
if (handle.includes("n")) top = bottom - side(bottom - top - dv);
const [x, y] = turnPoint((start.x0 + start.x1) / 2, (start.y0 + start.y1) / 2, (left + right) / 2, (top + bottom) / 2, angle);
const cx = clamp(x, 0, width);
const cy = clamp(y, 0, height);
const w = right - left;
const h = bottom - top;
return { x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2 };
}
// A box turned back to level: on whole pixels and on the canvas again, as
// level boxes always are.
export function leveledRect(rect, width, height) {
const w = clamp(Math.round(rect.x1 - rect.x0), 1, width);
const h = clamp(Math.round(rect.y1 - rect.y0), 1, height);
const x0 = clamp(Math.round(rect.x0), 0, width - w);
const y0 = clamp(Math.round(rect.y0), 0, height - h);
return { x0, y0, x1: x0 + w, y1: y0 + h };
}
// A canvas side as the node takes it: a whole number inside the limits.
export function canvasSide(value, fallback = DEFAULT_SIZE) {
const number = finiteNumber(value);
return number === null ? fallback : clamp(Math.round(number), DIM_MIN, DIM_MAX);
}
// The canvas the face draws, as { width, height, picture }: the picture on
// `background` when one is shown, at its own size and shape whatever width
// and height say (the mask is made on the picture, so the boxes are placed
// on it), else width and height as the node takes them.
export function faceCanvas(picture, width, height) {
const across = finiteNumber(Array.isArray(picture) ? picture[0] : null);
const down = finiteNumber(Array.isArray(picture) ? picture[1] : null);
if (across > 0 && down > 0) return { width: Math.round(across) || 1, height: Math.round(down) || 1, picture: true };
return { width: canvasSide(width), height: canvasSide(height), picture: false };
}
// The size a picture gives the canvas: its own size on the 16 px steps.
export function sideForPicture(pixels) {
const number = finiteNumber(pixels);
if (number === null || number <= 0) return null;
return clamp(Math.max(DIM_STEP, Math.round(number / DIM_STEP) * DIM_STEP), DIM_MIN, DIM_MAX);
}
// ---------- colors ----------
// "#abc", "#aabbcc" or "#aabbccdd" in lower case, or null.
export function normalizeHex(color) {
if (typeof color !== "string") return null;
const hex = color.trim().toLowerCase();
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex);
if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null;
}
export function cleanPalette(colors) {
const list = Array.isArray(colors) ? colors : colors && typeof colors === "object" ? Object.values(colors) : [];
return list.map(normalizeHex).filter(Boolean).slice(0, MAX_COLORS);
}
// What a box is drawn in: its first color, or the plain box color.
export function boxColor(box) {
return box?.palette?.find((color) => normalizeHex(color)) ?? PLAIN_BOX;
}
// Dark letters on a light color, light ones on a dark color.
export function textOn(color) {
const hex = normalizeHex(color) ?? PLAIN_BOX;
const [r, g, b] = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16));
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#061010" : "#ffffff";
}
// ---------- the saved form ----------
function cleanBox(item) {
if (!item || typeof item !== "object") return null;
const numbers = ["x", "y", "w", "h"].map((key) => finiteNumber(item[key]));
if (numbers.some((number) => number === null)) return null;
const [x, y, w, h] = numbers;
const box = {
x, y, w, h,
type: item.type === "text" ? "text" : "obj",
text: typeof item.text === "string" ? item.text : "",
desc: typeof item.desc === "string" ? item.desc : "",
palette: cleanPalette(item.palette),
};
// Only a turned box carries its angle; a level one reads as it always did.
const angle = readAngle(item.angle);
if (angle) box.angle = angle;
return box;
}
// Pixel boxes in Create Bounding Boxes' own form ({ x, y, width, height,
// metadata }) as boxes of this canvas.
export function fromCoreBoxes(list, width, height) {
const across = width || 1;
const down = height || 1;
const boxes = [];
for (const item of Array.isArray(list) ? list : []) {
if (!item || typeof item !== "object") continue;
const numbers = ["x", "y", "width", "height"].map((key) => finiteNumber(item[key] ?? 0));
if (numbers.some((number) => number === null)) continue;
const meta = item.metadata && typeof item.metadata === "object" ? item.metadata : {};
boxes.push(cleanBox({
x: numbers[0] / across, y: numbers[1] / down, w: numbers[2] / across, h: numbers[3] / down,
type: meta.type, text: meta.text, desc: meta.desc, palette: meta.palette,
}));
}
return boxes;
}
// The hidden widget's value as { boxes, fromInput, broken }. The node writes
// { v: 1, boxes: [...], from_input }; a list in Create Bounding Boxes' form
// is read against the canvas size. This never throws.
export function parseLayout(value, width = DEFAULT_SIZE, height = DEFAULT_SIZE) {
let data = value;
if (typeof value === "string") {
if (!value.trim()) return { boxes: [], fromInput: "", broken: false };
try {
data = JSON.parse(value);
} catch {
return { boxes: [], fromInput: "", broken: true };
}
}
if (Array.isArray(data)) return { boxes: fromCoreBoxes(data, width, height), fromInput: "", broken: false };
if (!data || typeof data !== "object") return { boxes: [], fromInput: "", broken: value !== null && value !== undefined };
const boxes = (Array.isArray(data.boxes) ? data.boxes : []).map(cleanBox).filter(Boolean);
return { boxes, fromInput: typeof data.from_input === "string" ? data.from_input : "", broken: false };
}
export function serializeLayout(boxes, fromInput = "") {
const data = {
v: 1,
boxes: boxes.map((box) => {
const saved = {
x: box.x, y: box.y, w: box.w, h: box.h,
type: box.type === "text" ? "text" : "obj",
text: box.text ?? "",
desc: box.desc ?? "",
palette: cleanPalette(box.palette),
};
const angle = angleOf(box);
if (angle) saved.angle = angle;
return saved;
}),
};
if (fromInput) data.from_input = fromInput;
return JSON.stringify(data);
}
export function newBox(rect, width, height, extra = {}) {
return fromPixels(rect, width, height, { type: "obj", text: "", desc: "", palette: [], ...extra });
}
// ---------- pixels ----------
export function boxPixels(box, width, height) {
return { x0: box.x * width, y0: box.y * height, x1: (box.x + box.w) * width, y1: (box.y + box.h) * height };
}
// Where a gesture starts: the box on whole pixels.
export function wholePixels(box, width, height) {
const rect = boxPixels(box, width, height);
return { x0: Math.round(rect.x0), y0: Math.round(rect.y0), x1: Math.round(rect.x1), y1: Math.round(rect.y1) };
}
// A rectangle in whole pixels back to fractions, one division per number.
export function fromPixels(rect, width, height, base = {}) {
return {
...base,
x: rect.x0 / width,
y: rect.y0 / height,
w: (rect.x1 - rect.x0) / width,
h: (rect.y1 - rect.y0) / height,
};
}
export function boxSizePx(box, width, height) {
const rect = wholePixels(box, width, height);
return [rect.x1 - rect.x0, rect.y1 - rect.y0];
}
// ---------- the grid ----------
export function gridCell(side) {
return Math.max(GRID_MIN_CELL, Math.ceil(Math.max(1, side) / GRID_MAX_CELLS));
}
// The grid sizes the gear offers. Auto is the rule above.
export const GRID_SIZES = ["Auto", "8", "16", "32", "64"];
// A side's grid cell for a size from the gear: that many pixels, or Auto's
// rule for Auto and for anything it does not know.
export function gridCellOf(side, size = "Auto") {
const fixed = GRID_SIZES.includes(String(size)) ? Number(size) : NaN;
return Number.isFinite(fixed) && fixed > 0 ? fixed : gridCell(side);
}
// The nearest grid line, or the far edge of the canvas when that is nearer
// (a side that is not a whole number of cells still has its edge to snap to).
export function snapTo(value, cell, side) {
const at = clamp(value, 0, side);
const line = Math.round(at / cell) * cell;
return clamp(Math.abs(side - at) < Math.abs(line - at) ? side : line, 0, side);
}
// { snap, cellX, cellY } for a canvas, the grid on or off, at a size from
// the gear (Auto when none is given).
export function snapping(width, height, on = true, size = "Auto") {
return { snap: Boolean(on), cellX: gridCellOf(width, size), cellY: gridCellOf(height, size) };
}
// Whether the grid is on: the person's own choice once they made one (saved
// with the node), else on for an empty canvas and off over a picture, where
// a box has to fit what is in the picture rather than the cells.
export function gridIsOn(saved, overPicture) {
return typeof saved === "boolean" ? saved : !overPicture;
}
function edge(value, cell, side, snap) {
return snap ? snapTo(value, cell, side) : clamp(Math.round(value), 0, side);
}
function smallest(cell, snap, side) {
return Math.min(side, snap ? cell : FREE_MIN_PX);
}
// A new box from where the drag started to where the pointer is, in canvas
// pixels, any direction. It is at least one cell (or FREE_MIN_PX) each way.
export function drawnRect(ax, ay, bx, by, width, height, { snap = true, cellX = GRID_MIN_CELL, cellY = GRID_MIN_CELL } = {}) {
const span = (a, b, cell, side) => {
let lo = edge(Math.min(a, b), cell, side, snap);
let hi = edge(Math.max(a, b), cell, side, snap);
const least = smallest(cell, snap, side);
if (hi - lo < least) {
if (b >= a) hi = lo + least;
else lo = hi - least;
if (hi > side) { hi = side; lo = side - least; }
if (lo < 0) { lo = 0; hi = least; }
}
return [lo, hi];
};
const [x0, x1] = span(ax, bx, cellX, width);
const [y0, y1] = span(ay, by, cellY, height);
return { x0, y0, x1, y1 };
}
// One side of a moved box: its near edge on a grid line, or its far edge on
// one (or on the canvas edge), whichever is nearer to where it was dragged.
function movedSide(start, delta, size, side, cell, snap) {
const raw = start + delta;
if (!snap) return clamp(Math.round(raw), 0, Math.max(0, side - size));
const near = Math.round(raw / cell) * cell;
const far = snapTo(raw + size, cell, side) - size;
const pick = Math.abs(near - raw) <= Math.abs(far - raw) ? near : far;
return clamp(pick, 0, Math.max(0, side - size));
}
// The box moved by (dx, dy) canvas pixels from where it started. It keeps
// its size and stays on the canvas.
export function movedRect(start, dx, dy, width, height, { snap = true, cellX = GRID_MIN_CELL, cellY = GRID_MIN_CELL } = {}) {
const w = start.x1 - start.x0;
const h = start.y1 - start.y0;
const x0 = movedSide(start.x0, dx, w, width, cellX, snap);
const y0 = movedSide(start.y0, dy, h, height, cellY, snap);
return { x0, y0, x1: x0 + w, y1: y0 + h };
}
// One handle dragged by (dx, dy). The edges it does not hold stay where
// they are, and the box keeps at least one cell (or FREE_MIN_PX) each way.
export function resizedRect(start, handle, dx, dy, width, height, { snap = true, cellX = GRID_MIN_CELL, cellY = GRID_MIN_CELL } = {}) {
let { x0, y0, x1, y1 } = start;
const leastX = smallest(cellX, snap, width);
const leastY = smallest(cellY, snap, height);
if (handle.includes("w")) x0 = clamp(edge(start.x0 + dx, cellX, width, snap), 0, Math.max(0, x1 - leastX));
if (handle.includes("e")) x1 = clamp(edge(start.x1 + dx, cellX, width, snap), Math.min(width, x0 + leastX), width);
if (handle.includes("n")) y0 = clamp(edge(start.y0 + dy, cellY, height, snap), 0, Math.max(0, y1 - leastY));
if (handle.includes("s")) y1 = clamp(edge(start.y1 + dy, cellY, height, snap), Math.min(height, y0 + leastY), height);
return { x0, y0, x1, y1 };
}
// How far an arrow key moves the chosen box, as [dx, dy] in canvas pixels,
// or null for any other key: one pixel, ten with Shift. The grid steers only
// the mouse, so the keys reach every pixel with it on or off, and Alt + arrow
// is one pixel as it always was.
export function nudgeStep(key, { shiftKey = false } = {}) {
const step = shiftKey ? NUDGE_SHIFT_PX : NUDGE_PX;
if (key === "ArrowLeft") return [-step, 0];
if (key === "ArrowRight") return [step, 0];
if (key === "ArrowUp") return [0, -step];
if (key === "ArrowDown") return [0, step];
return null;
}
// A nudge from the keyboard: an exact step, the box kept on the canvas.
export function nudgedRect(start, dx, dy, width, height) {
const w = start.x1 - start.x0;
const h = start.y1 - start.y0;
const x0 = clamp(start.x0 + dx, 0, Math.max(0, width - w));
const y0 = clamp(start.y0 + dy, 0, Math.max(0, height - h));
return { x0, y0, x1: x0 + w, y1: y0 + h };
}
// Where a copy goes: one cell down and right, or up and left at the edge.
export function copiedRect(start, width, height, cellX = GRID_MIN_CELL, cellY = GRID_MIN_CELL) {
const w = start.x1 - start.x0;
const h = start.y1 - start.y0;
const step = (lo, size, cell, side) => (lo + cell + size <= side ? lo + cell : Math.max(0, lo - cell));
const x0 = step(start.x0, w, cellX, width);
const y0 = step(start.y0, h, cellY, height);
return { x0, y0, x1: x0 + w, y1: y0 + h };
}
// ---------- the marks around a box ----------
// The handles, the tag and the outline keep one size on the screen at any
// graph zoom; these sizes are screen pixels.
export const HANDLE_PX = 14;
export const TAG_HEIGHT_PX = 15;
// A side this long holds a handle at its middle as well as at its corners.
export const MIDDLE_HANDLE_FROM_PX = 3 * HANDLE_PX + 6;
// A box's type in words, as its tag and its chip say it.
export function boxKind(box) {
return box?.type === "text" ? "text" : "object";
}
// What a box's tag says: its number, its type in words, and how far it is
// turned when it is ("1 text 25°"). The chips say the first two.
export function boxTag(box, index) {
const angle = angleOf(box);
return `${index + 1} ${boxKind(box)}${angle ? ` ${angleLabel(angle)}` : ""}`;
}
// The tag's width on the screen, a little over: its letters are monospace.
export function tagWidthPx(tag) {
return Math.ceil(String(tag).length * 6.5) + 10;
}
// Which marks a box gets, from its place on the screen ({ x, y, w, h } in
// screen pixels from the canvas's top-left corner) and the canvas's width on
// the screen. A side shorter than MIDDLE_HANDLE_FROM_PX has handles at its
// corners only. The tag sits just outside the top-left corner, inside the
// box when the box touches the top of the canvas, and runs leftward from the
// box's right edge when it would leave the canvas on the right.
export function boxMarks(rect, canvasWidth, tagWidth) {
return {
middleTopBottom: rect.w >= MIDDLE_HANDLE_FROM_PX,
middleLeftRight: rect.h >= MIDDLE_HANDLE_FROM_PX,
tagInside: rect.y < TAG_HEIGHT_PX,
tagFromRight: rect.x + tagWidth > canvasWidth,
};
}
// The turn handle (the knob) sits this far from the box's edge, centre to
// edge, in screen pixels; it is as big as the other handles.
export const KNOB_GAP_PX = 22;
// True when every point lies on a canvas of this size (screen pixels), with
// `room` to spare.
function onCanvas(points, canvasWidth, canvasHeight, room = 0) {
return points.every(([x, y]) => x >= room - 0.5 && y >= room - 0.5 && x <= canvasWidth - room + 0.5 && y <= canvasHeight - room + 0.5);
}
// Where the knob goes, from the box's place on the screen ({ x, y, w, h } as
// drawn, before the turn) and its angle: "above" its top edge, "below" its
// bottom edge when the canvas has no room above, else "in" the box under its
// top edge. The canvas cuts off whatever is outside it.
export function knobSide(rect, angle, canvasWidth, canvasHeight) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
const reach = rect.h / 2 + KNOB_GAP_PX;
for (const [side, v] of [["above", -reach], ["below", reach]]) {
if (onCanvas([turnPoint(cx, cy, 0, v, angle)], canvasWidth, canvasHeight, HANDLE_PX / 2)) return side;
}
return "in";
}
// Where a turned box's tag goes: the first of above its top-left corner,
// above its top-right corner (running leftward), inside under the top-left
// corner and inside under the top-right corner that stays on the canvas, as
// a level box's tag does. Level boxes keep boxMarks' rule.
export function turnedTagPlace(rect, angle, canvasWidth, canvasHeight, tagWidth) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
const hw = rect.w / 2;
const hh = rect.h / 2;
const tall = TAG_HEIGHT_PX;
const places = [
[false, false, [-hw, -hh - tall], [-hw + tagWidth, -hh]],
[false, true, [hw - tagWidth, -hh - tall], [hw, -hh]],
[true, false, [-hw, -hh], [-hw + tagWidth, -hh + tall]],
[true, true, [hw - tagWidth, -hh], [hw, -hh + tall]],
];
for (const [inside, fromRight, [u0, v0], [u1, v1]] of places) {
const corners = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]].map(([u, v]) => turnPoint(cx, cy, u, v, angle));
if (onCanvas(corners, canvasWidth, canvasHeight)) return { tagInside: inside, tagFromRight: fromRight };
}
return { tagInside: true, tagFromRight: false };
}
// The resize cursor for a handle of a box turned by `angle`: the one of the
// four that points the way the handle pulls on the screen.
const RESIZE_CURSORS = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"];
const HANDLE_HEADING = { e: 0, se: 45, s: 90, sw: 135, w: 180, nw: 225, n: 270, ne: 315 };
export function handleCursor(handle, angle) {
const heading = (HANDLE_HEADING[handle] ?? 0) + readAngle(angle);
return RESIZE_CURSORS[((Math.round(heading / 45) % 4) + 4) % 4];
}
// ---------- words ----------
// What a box says on the canvas: the text in quotes for a text box, then
// the description.
export function boxWords(box) {
const desc = String(box?.desc ?? "").trim();
const text = String(box?.text ?? "").trim();
if (box?.type === "text" && text) return desc ? `"${text}" - ${desc}` : `"${text}"`;
return desc;
}
// ---------- choosing a box ----------
// The editor has a chip per box, up to this many; with more boxes, the chips
// of the boxes around the chosen one, and ‹ › to step through the rest.
export const CHIPS_MAX = 6;
// The boxes that have a chip, as [first, end): every box when they fit, else
// CHIPS_MAX of them with the chosen one among them.
export function chipWindow(count, selected, max = CHIPS_MAX) {
if (count <= max) return [0, Math.max(0, count)];
const start = clamp(Math.max(0, selected) - Math.floor((max - 1) / 2), 0, count - max);
return [start, start + max];
}
// An object box keeps the words typed while it was a text box, but they do
// not go to the model. The line the editor shows in their place, or "" when
// there is nothing to say.
export const KEPT_WORDS_NOTE = "Object boxes do not write words. Your words are kept for when you switch back to text.";
export function keptWordsNote(box) {
return box && box.type !== "text" && String(box.text ?? "").trim() ? KEPT_WORDS_NOTE : "";
}
// ---------- ready-made layouts ----------
// Places as fractions [x, y, w, h]; a `type` is what a new box there is.
export const LAYOUTS = [
{ id: "middle", label: "One in the middle", boxes: [{ at: [0.2, 0.1, 0.6, 0.82] }] },
{ id: "two", label: "Two side by side", boxes: [{ at: [0.04, 0.1, 0.44, 0.82] }, { at: [0.52, 0.1, 0.44, 0.82] }] },
{ id: "three", label: "Three across", boxes: [{ at: [0.03, 0.12, 0.3, 0.76] }, { at: [0.35, 0.12, 0.3, 0.76] }, { at: [0.67, 0.12, 0.3, 0.76] }] },
{ id: "stack", label: "Top and bottom", boxes: [{ at: [0.06, 0.04, 0.88, 0.44] }, { at: [0.06, 0.52, 0.88, 0.44] }] },
{ id: "title", label: "Title over a picture", boxes: [{ at: [0.1, 0.05, 0.8, 0.14], type: "text" }, { at: [0.1, 0.24, 0.8, 0.7] }] },
{ id: "corners", label: "Four corners", boxes: [
{ at: [0.04, 0.04, 0.44, 0.44] }, { at: [0.52, 0.04, 0.44, 0.44] },
{ at: [0.04, 0.52, 0.44, 0.44] }, { at: [0.52, 0.52, 0.44, 0.44] },
] },
];
// A layout's places for this canvas, on the grid. The boxes already there
// move into them and keep their words, type and colors: in order, except
// that a place meant for text takes the first text box (and an empty text
// box when there is none). Boxes the layout has no place for stay where
// they are, so nothing typed is lost.
export function applyLayout(layout, existing, width, height, grid = snapping(width, height)) {
const left = [...existing];
const take = (wanted) => {
const at = wanted ? left.findIndex((box) => box.type === wanted) : 0;
return at >= 0 && left.length ? left.splice(at, 1)[0] : null;
};
const placed = layout.boxes.map((slot) => {
const [fx, fy, fw, fh] = slot.at;
const rect = drawnRect(fx * width, fy * height, (fx + fw) * width, (fy + fh) * height, width, height, grid);
const old = take(slot.type === "text" ? "text" : null);
const base = old
? { type: old.type, text: old.text, desc: old.desc, palette: [...(old.palette ?? [])] }
: { type: slot.type === "text" ? "text" : "obj", text: "", desc: "", palette: [] };
return fromPixels(rect, width, height, base);
});
return [...placed, ...left];
}
// ---------- a size on a wire ----------
function linkOf(graph, id) {
if (id === null || id === undefined || !graph) return null;
return graph.getLink?.(id) ?? graph.links?.get?.(id) ?? graph.links?.[id] ?? null;
}
// The link into a node's input, found by the input's name. The frontend's
// own accessor first (newer frontends warn on reading input.link), the link
// table after it.
export function inputLink(node, name) {
const index = node?.inputs?.findIndex((slot) => slot?.name === name || slot?.widget?.name === name) ?? -1;
return index < 0 ? null : inputLinkAt(node, index);
}
function inputLinkAt(node, index) {
if (typeof node?.getInputLink === "function" && node.graph) {
try {
return node.getInputLink(index) ?? null;
} catch {
return null;
}
}
return linkOf(node?.graph, node?.inputs?.[index]?.link);
}
const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
// The number an input gets from its wire, when the page can know it before
// a run: the widget of the same name as the output on the node it comes
// from (Latent Size's width and height), or the value of a number node
// (Integer, Primitive). Reroutes are followed. Null when the input is not
// wired or the value is only known once the workflow runs.
export function wiredNumber(node, inputName) {
const source = wiredSource(node, inputName);
return source ? wiredFrom(source.node, source.slot) : null;
}
// The node (and its output slot) an input is wired to, through Reroutes.
export function wiredSource(node, inputName) {
const graph = node?.graph;
let link = inputLink(node, inputName);
for (let hops = 0; link && hops < 16; hops += 1) {
const source = graph?.getNodeById?.(link.origin_id) ?? null;
if (!source) return null;
if ((source.comfyClass ?? source.type) !== "Reroute") return { node: source, slot: link.origin_slot };
link = inputLinkAt(source, 0);
}
return null;
}
function wiredFrom(source, slot) {
const output = source.outputs?.[slot];
const widgets = source.widgets ?? [];
const named = widgets.find((widget) => widget?.name === output?.name && isNumber(widget.value));
if (named) return named.value;
const value = widgets.find((widget) => widget?.name === "value" && isNumber(widget.value));
if (value) return value.value;
// ComfyUI's Primitive node holds the value of the widget it feeds.
if ((source.comfyClass ?? source.type) === "PrimitiveNode" && isNumber(widgets[0]?.value)) return widgets[0].value;
return null;
}