// 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; }