Files
glonlas-ComfyUI-image-profile/web/image_profile.js
T
Gérald LONLASandClaude Opus 4.8 7fe163ff5f fix(web): align sanitizeSteps NaN fallback with STEPS_MIN (#2)
When a non-numeric value reached sanitizeSteps(), the NaN branch fell
back to a hardcoded 8 instead of STEPS_MIN (1). This was inconsistent
with sanitizeDimension() (which falls back to DIM_MIN) and with the
Python backend's _sanitize_steps(), which clamps invalid input to
STEPS_MIN. A user clearing the steps field could see a different value
applied client-side than the server would compute.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:35:03 +08:00

1073 lines
28 KiB
JavaScript

import { app } from "../../scripts/app.js";
const EXTENSION_NAME = "ComfyUI.ImageProfile.Manager";
const TARGET_NODE_NAMES = new Set(["ComfyUIImageProfile", "ComfyUI-Image-profile", "Image Profile"]);
const STATE_WIDGET_NAMES = [
"profiles_json",
"selected_profile_id",
"selected_width",
"selected_height",
"selected_steps",
];
const STYLE_TAG_ID = "comfyui-image-profile-style";
const CUSTOM_RESOLUTION_VALUE = "__custom__";
const DIM_MULTIPLE = 8;
const DIM_MIN = 8;
const DIM_MAX = 16384;
const STEPS_MIN = 1;
const STEPS_MAX = 150;
const DEFAULT_NODE_MIN_WIDTH = 460;
const DEFAULT_NODE_MIN_HEIGHT = 520;
const RESOLUTION_PRESETS = [
"1024x1024 ( 1:1 )",
"1152x896 ( 9:7 )",
"896x1152 ( 7:9 )",
"1152x864 ( 4:3 )",
"864x1152 ( 3:4 )",
"1248x832 ( 3:2 )",
"832x1248 ( 2:3 )",
"1280x720 ( 16:9 )",
"720x1280 ( 9:16 )",
"1344x576 ( 21:9 )",
"576x1344 ( 9:21 )",
"1280x1280 ( 1:1 )",
"1440x1120 ( 9:7 )",
"1120x1440 ( 7:9 )",
"1472x1104 ( 4:3 )",
"1104x1472 ( 3:4 )",
"1536x1024 ( 3:2 )",
"1024x1536 ( 2:3 )",
"1536x864 ( 16:9 )",
"864x1536 ( 9:16 )",
"1680x720 ( 21:9 )",
"720x1680 ( 9:21 )",
"1536x1536 ( 1:1 )",
"1728x1344 ( 9:7 )",
"1344x1728 ( 7:9 )",
"1728x1296 ( 4:3 )",
"1296x1728 ( 3:4 )",
"1872x1248 ( 3:2 )",
"1248x1872 ( 2:3 )",
"2048x1152 ( 16:9 )",
"1152x2048 ( 9:16 )",
"2016x864 ( 21:9 )",
"864x2016 ( 9:21 )",
];
const DEFAULT_PROFILES = [
{ id: "default-landscape-low", name: "Landscape Low resolution", width: 404, height: 204, steps: 5 },
{ id: "default-portrait-low", name: "Portrait Low resolution", width: 204, height: 404, steps: 5 },
{ id: "default-landscape-high", name: "Landscape High resolution", width: 1152, height: 864, steps: 8 },
{ id: "default-portrait-high", name: "Portrait High resolution", width: 864, height: 1152, steps: 8 },
];
function ensureStyles() {
if (document.getElementById(STYLE_TAG_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_TAG_ID;
style.textContent = `
.cip-root {
--cip-bg: linear-gradient(165deg, #0d1a1f 0%, #0e131c 100%);
--cip-border: rgba(255, 255, 255, 0.12);
--cip-text: #e8f0ff;
--cip-muted: #9aa8c7;
--cip-accent: #3ba3ff;
--cip-accent-2: #69d1ff;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--cip-border);
border-radius: 12px;
background: var(--cip-bg);
color: var(--cip-text);
box-sizing: border-box;
width: 100%;
min-height: 320px;
overflow: hidden;
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
}
.cip-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.cip-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--cip-text);
}
.cip-add {
border: 1px solid rgba(105, 209, 255, 0.45);
border-radius: 8px;
background: linear-gradient(160deg, rgba(59, 163, 255, 0.2), rgba(105, 209, 255, 0.08));
color: var(--cip-text);
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.cip-add:hover {
filter: brightness(1.15);
transform: translateY(-1px);
}
.cip-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 150px;
overflow-y: visible;
overflow-x: hidden;
padding-right: 2px;
padding-bottom: 4px;
box-sizing: border-box;
}
.cip-profile {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
padding: 8px;
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 7px;
flex: 0 0 auto;
box-sizing: border-box;
width: 100%;
overflow: hidden;
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
}
.cip-profile.is-selected {
border-color: rgba(59, 163, 255, 0.98);
background: linear-gradient(160deg, rgba(59, 163, 255, 0.28), rgba(105, 209, 255, 0.18));
box-shadow: inset 0 0 0 1px rgba(59, 163, 255, 0.5), 0 0 0 1px rgba(59, 163, 255, 0.32);
}
.cip-profile.is-drop-target {
border-color: rgba(105, 209, 255, 0.95);
box-shadow: inset 0 0 0 1px rgba(105, 209, 255, 0.45);
}
.cip-profile-top {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: baseline;
min-width: 0;
}
.cip-profile-name {
font-size: 12px;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cip-selected-pill {
display: inline-flex;
align-items: center;
border: 1px solid rgba(105, 209, 255, 0.9);
border-radius: 999px;
padding: 2px 7px;
font-size: 10px;
letter-spacing: 0.02em;
color: #bde9ff;
background: rgba(59, 163, 255, 0.25);
}
.cip-profile-meta {
font-size: 11px;
color: var(--cip-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cip-profile-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.cip-actions {
display: flex;
gap: 6px;
margin-left: auto;
}
.cip-btn {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
color: var(--cip-text);
font-size: 11px;
line-height: 1;
padding: 5px 7px;
cursor: pointer;
}
.cip-btn:hover {
filter: brightness(1.12);
}
.cip-btn-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.cip-icon {
width: 13px;
height: 13px;
display: inline-flex;
color: var(--cip-text);
}
.cip-icon svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.cip-empty {
color: var(--cip-muted);
font-size: 12px;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 10px;
padding: 14px 10px;
text-align: center;
}
.cip-form {
margin-top: 8px;
border: 1px solid rgba(105, 209, 255, 0.3);
border-radius: 10px;
background: rgba(10, 16, 24, 0.65);
padding: 10px;
display: none;
flex-direction: column;
gap: 7px;
}
.cip-form.is-open {
display: flex;
}
.cip-label {
font-size: 11px;
color: var(--cip-muted);
margin-bottom: 2px;
}
.cip-input, .cip-select {
width: 100%;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.25);
color: var(--cip-text);
font-size: 12px;
padding: 6px 8px;
box-sizing: border-box;
}
.cip-row {
display: flex;
gap: 8px;
}
.cip-col {
flex: 1;
}
.cip-form-actions {
display: flex;
justify-content: flex-end;
gap: 7px;
margin-top: 4px;
}
`;
document.head.appendChild(style);
}
function getWidget(node, name) {
return node.widgets?.find((widget) => widget?.name === name);
}
function setWidgetValue(node, name, value) {
const widget = getWidget(node, name);
if (!widget) {
return;
}
widget.value = value;
widget.callback?.(value);
}
function hideStateWidgets(node) {
for (const widgetName of STATE_WIDGET_NAMES) {
const widget = getWidget(node, widgetName);
if (!widget) {
continue;
}
widget.hidden = true;
widget.type = "hidden";
widget.computeSize = () => [0, -4];
}
}
function fitNode(node, { enforceDefaultMinimum = false } = {}) {
const computed = node.computeSize?.();
if (!computed || computed.length < 2) {
return;
}
const currentWidth = Number(node.size?.[0]) || 0;
const currentHeight = Number(node.size?.[1]) || 0;
const minWidth = enforceDefaultMinimum ? DEFAULT_NODE_MIN_WIDTH : 0;
const minHeight = enforceDefaultMinimum ? DEFAULT_NODE_MIN_HEIGHT : 0;
const targetWidth = Math.max(computed[0], minWidth);
const targetHeight = Math.max(computed[1], minHeight);
const nextWidth = Math.max(targetWidth, currentWidth);
const nextHeight = Math.max(targetHeight, currentHeight);
if (nextWidth !== currentWidth || nextHeight !== currentHeight) {
node.setSize?.([nextWidth, nextHeight]);
}
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
}
function createId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `profile-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
function createIconButton(kind, label) {
const button = document.createElement("button");
button.className = "cip-btn cip-btn-icon";
button.type = "button";
button.title = label;
button.setAttribute("aria-label", label);
const icon = document.createElement("span");
icon.className = "cip-icon";
if (kind === "edit") {
icon.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"></path></svg>`;
} else {
icon.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M8 6V4h8v2"></path><path d="M19 6l-1 14H6L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path></svg>`;
}
button.appendChild(icon);
return button;
}
function parseResolution(text) {
const match = String(text ?? "").trim().match(/^(\d+)\s*x\s*(\d+)(?:\s|$)/i);
if (!match) {
return null;
}
return {
width: Number.parseInt(match[1], 10),
height: Number.parseInt(match[2], 10),
};
}
function toProfile(candidate) {
if (!candidate || typeof candidate !== "object") {
return null;
}
const name = String(candidate.name ?? "").trim();
const id = String(candidate.id ?? "").trim() || createId();
const width = Number.parseInt(candidate.width, 10);
const height = Number.parseInt(candidate.height, 10);
const steps = Number.parseInt(candidate.steps, 10);
if (!name || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(steps)) {
return null;
}
return {
id,
name,
width,
height,
steps,
};
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function sanitizeDimension(value) {
let integer = Number.parseInt(value, 10);
if (Number.isNaN(integer)) {
integer = DIM_MIN;
}
integer = clamp(integer, DIM_MIN, DIM_MAX);
const lower = Math.max(DIM_MIN, Math.floor(integer / DIM_MULTIPLE) * DIM_MULTIPLE);
const upper = Math.min(DIM_MAX, lower + DIM_MULTIPLE);
const nextValue = integer - lower >= upper - integer ? upper : lower;
return {
value: nextValue,
corrected: nextValue !== Number.parseInt(value, 10),
};
}
function sanitizeSteps(value) {
let integer = Number.parseInt(value, 10);
if (Number.isNaN(integer)) {
integer = STEPS_MIN;
}
const nextValue = clamp(integer, STEPS_MIN, STEPS_MAX);
return {
value: nextValue,
corrected: nextValue !== Number.parseInt(value, 10),
};
}
function notify(message, severity = "info") {
if (typeof app.extensionManager?.toast?.add === "function") {
app.extensionManager.toast.add({
severity,
summary: "Image Profile",
detail: message,
life: 3500,
});
return;
}
app.ui.dialog.show(message);
}
function makeUniqueName(baseName, profiles, excludeId = null) {
const desired = String(baseName ?? "").trim() || "Profile";
const taken = new Set(
profiles
.filter((profile) => profile.id !== excludeId)
.map((profile) => profile.name.trim().toLowerCase()),
);
if (!taken.has(desired.toLowerCase())) {
return desired;
}
let index = 2;
while (true) {
const candidate = `${desired} (${index})`;
if (!taken.has(candidate.toLowerCase())) {
return candidate;
}
index += 1;
}
}
function loadProfilesFromWidgets(node) {
const profilesWidget = getWidget(node, "profiles_json");
const raw = String(profilesWidget?.value ?? "[]").trim();
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
parsed = [];
}
if (!Array.isArray(parsed) || parsed.length === 0) {
return DEFAULT_PROFILES.map((profile) => ({ ...profile }));
}
const sanitized = parsed.map(toProfile).filter((value) => value !== null);
if (sanitized.length === 0) {
return DEFAULT_PROFILES.map((profile) => ({ ...profile }));
}
return sanitized;
}
function initializeState(node) {
const profiles = loadProfilesFromWidgets(node);
const selectedProfileId = String(getWidget(node, "selected_profile_id")?.value ?? "").trim();
let selectedId = selectedProfileId;
if (!profiles.some((profile) => profile.id === selectedId)) {
selectedId = profiles[0]?.id ?? "";
}
return {
profiles,
selectedId,
editingId: null,
};
}
function syncStateFromWidgets(node) {
if (!node.__imageProfileManagerMounted) {
return;
}
node.__imageProfileState = initializeState(node);
closeForm(node);
renderProfiles(node);
}
function chooseActiveProfile(state) {
return state.profiles.find((profile) => profile.id === state.selectedId) ?? state.profiles[0] ?? null;
}
function normalizeSelection(state) {
if (state.profiles.length === 0) {
state.selectedId = "";
return;
}
if (!state.profiles.some((profile) => profile.id === state.selectedId)) {
state.selectedId = state.profiles[0].id;
}
}
function persistState(node) {
const state = node.__imageProfileState;
if (!state) {
return;
}
normalizeSelection(state);
setWidgetValue(node, "profiles_json", JSON.stringify(state.profiles));
const active = chooseActiveProfile(state);
if (active) {
state.selectedId = active.id;
setWidgetValue(node, "selected_profile_id", active.id);
setWidgetValue(node, "selected_width", active.width);
setWidgetValue(node, "selected_height", active.height);
setWidgetValue(node, "selected_steps", active.steps);
} else {
setWidgetValue(node, "selected_profile_id", "");
setWidgetValue(node, "selected_width", 1152);
setWidgetValue(node, "selected_height", 864);
setWidgetValue(node, "selected_steps", 8);
}
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
}
function createField(labelText, inputElement) {
const wrapper = document.createElement("div");
const label = document.createElement("div");
label.className = "cip-label";
label.textContent = labelText;
wrapper.append(label, inputElement);
return wrapper;
}
function buildFormUI(root) {
const form = document.createElement("div");
form.className = "cip-form";
const nameInput = document.createElement("input");
nameInput.className = "cip-input";
nameInput.type = "text";
nameInput.placeholder = "Portrait Low resolution";
const presetSelect = document.createElement("select");
presetSelect.className = "cip-select";
const customOption = document.createElement("option");
customOption.value = CUSTOM_RESOLUTION_VALUE;
customOption.textContent = "Custom (enter WxH)";
presetSelect.appendChild(customOption);
for (const preset of RESOLUTION_PRESETS) {
const option = document.createElement("option");
option.value = preset;
option.textContent = preset;
presetSelect.appendChild(option);
}
const customResolutionInput = document.createElement("input");
customResolutionInput.className = "cip-input";
customResolutionInput.type = "text";
customResolutionInput.placeholder = "1152x864";
const stepsInput = document.createElement("input");
stepsInput.className = "cip-input";
stepsInput.type = "number";
stepsInput.step = "1";
stepsInput.min = "1";
stepsInput.max = "150";
stepsInput.value = "8";
const row = document.createElement("div");
row.className = "cip-row";
const left = document.createElement("div");
left.className = "cip-col";
left.append(createField("Preset Resolution", presetSelect));
const right = document.createElement("div");
right.className = "cip-col";
right.append(createField("Steps", stepsInput));
row.append(left, right);
form.append(
createField("Profile Name", nameInput),
row,
createField("Custom Resolution (WxH)", customResolutionInput),
);
const actions = document.createElement("div");
actions.className = "cip-form-actions";
const cancelButton = document.createElement("button");
cancelButton.className = "cip-btn";
cancelButton.type = "button";
cancelButton.textContent = "Cancel";
const saveButton = document.createElement("button");
saveButton.className = "cip-btn";
saveButton.type = "button";
saveButton.textContent = "Save";
actions.append(cancelButton, saveButton);
form.appendChild(actions);
root.appendChild(form);
return {
form,
nameInput,
presetSelect,
customResolutionInput,
stepsInput,
saveButton,
cancelButton,
};
}
function renderProfiles(node) {
const state = node.__imageProfileState;
const list = node.__imageProfileListEl;
if (!state || !list) {
return;
}
list.innerHTML = "";
if (state.profiles.length === 0) {
const empty = document.createElement("div");
empty.className = "cip-empty";
empty.textContent = "No profiles yet. Click Add Profile to create one.";
list.appendChild(empty);
fitNode(node);
return;
}
for (const profile of state.profiles) {
const isSelected = profile.id === state.selectedId;
const card = document.createElement("div");
card.className = `cip-profile${isSelected ? " is-selected" : ""}`;
card.draggable = true;
const top = document.createElement("div");
top.className = "cip-profile-top";
const name = document.createElement("div");
name.className = "cip-profile-name";
name.textContent = profile.name;
const meta = document.createElement("div");
meta.className = "cip-profile-meta";
meta.textContent = `${profile.width}x${profile.height} • ${profile.steps} steps`;
if (isSelected) {
const selectedPill = document.createElement("div");
selectedPill.className = "cip-selected-pill";
selectedPill.textContent = "Selected";
top.append(name, selectedPill);
} else {
top.append(name);
}
const actions = document.createElement("div");
actions.className = "cip-actions";
const editButton = createIconButton("edit", "Edit profile");
const deleteButton = createIconButton("delete", "Delete profile");
editButton.addEventListener("click", (event) => {
event.stopPropagation();
openForm(node, "edit", profile.id);
});
deleteButton.addEventListener("click", (event) => {
event.stopPropagation();
if (state.profiles.length <= 1) {
app.ui.dialog.show("At least one profile must exist.");
return;
}
state.profiles = state.profiles.filter((candidate) => candidate.id !== profile.id);
if (state.selectedId === profile.id) {
state.selectedId = state.profiles[0]?.id ?? "";
}
persistState(node);
renderProfiles(node);
});
actions.append(editButton, deleteButton);
const footer = document.createElement("div");
footer.className = "cip-profile-footer";
footer.append(meta, actions);
card.addEventListener("click", () => {
state.selectedId = profile.id;
persistState(node);
renderProfiles(node);
});
card.addEventListener("dragstart", (event) => {
node.__draggingProfileId = profile.id;
event.dataTransfer.effectAllowed = "move";
});
card.addEventListener("dragend", () => {
node.__draggingProfileId = null;
card.classList.remove("is-drop-target");
});
card.addEventListener("dragover", (event) => {
event.preventDefault();
card.classList.add("is-drop-target");
});
card.addEventListener("dragleave", () => {
card.classList.remove("is-drop-target");
});
card.addEventListener("drop", (event) => {
event.preventDefault();
card.classList.remove("is-drop-target");
const draggingId = node.__draggingProfileId;
if (!draggingId || draggingId === profile.id) {
return;
}
const sourceIndex = state.profiles.findIndex((entry) => entry.id === draggingId);
const targetIndex = state.profiles.findIndex((entry) => entry.id === profile.id);
if (sourceIndex === -1 || targetIndex === -1) {
return;
}
const reordered = [...state.profiles];
const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, moved);
state.profiles = reordered;
persistState(node);
renderProfiles(node);
});
card.append(top, footer);
list.appendChild(card);
}
fitNode(node);
}
function setFormOpen(node, isOpen) {
const form = node.__imageProfileForm?.form;
if (!form) {
return;
}
if (isOpen) {
form.classList.add("is-open");
} else {
form.classList.remove("is-open");
}
fitNode(node);
}
function resolvePresetValue(profile) {
const direct = `${profile.width}x${profile.height}`;
const matchingPreset = RESOLUTION_PRESETS.find((preset) => {
const parsed = parseResolution(preset);
return parsed && parsed.width === profile.width && parsed.height === profile.height;
});
if (matchingPreset) {
return {
preset: matchingPreset,
custom: direct,
};
}
return {
preset: CUSTOM_RESOLUTION_VALUE,
custom: direct,
};
}
function openForm(node, mode, profileId = null) {
const state = node.__imageProfileState;
const formState = node.__imageProfileForm;
if (!state || !formState) {
return;
}
const profile = mode === "edit"
? state.profiles.find((candidate) => candidate.id === profileId) ?? null
: null;
if (profile) {
const resolved = resolvePresetValue(profile);
formState.nameInput.value = profile.name;
formState.presetSelect.value = resolved.preset;
formState.customResolutionInput.value = resolved.custom;
formState.stepsInput.value = String(profile.steps);
state.editingId = profile.id;
} else {
formState.nameInput.value = "";
formState.presetSelect.value = RESOLUTION_PRESETS[0] ?? CUSTOM_RESOLUTION_VALUE;
formState.customResolutionInput.value = "";
formState.stepsInput.value = "8";
state.editingId = null;
}
setFormOpen(node, true);
}
function closeForm(node) {
const state = node.__imageProfileState;
if (state) {
state.editingId = null;
}
setFormOpen(node, false);
}
function saveForm(node) {
const state = node.__imageProfileState;
const formState = node.__imageProfileForm;
if (!state || !formState) {
return;
}
const name = String(formState.nameInput.value ?? "").trim();
const preset = formState.presetSelect.value;
const custom = String(formState.customResolutionInput.value ?? "").trim();
const resolutionText = preset === CUSTOM_RESOLUTION_VALUE ? custom : preset;
const parsedResolution = parseResolution(resolutionText);
const steps = Number.parseInt(formState.stepsInput.value, 10);
if (!name) {
app.ui.dialog.show("Profile name is required.");
return;
}
if (!parsedResolution) {
app.ui.dialog.show("Resolution must follow WxH format, for example 1152x864.");
return;
}
if (!/^-?\d+$/.test(String(formState.stepsInput.value ?? "").trim())) {
app.ui.dialog.show("Steps must be an integer value.");
return;
}
const widthSanitized = sanitizeDimension(parsedResolution.width);
const heightSanitized = sanitizeDimension(parsedResolution.height);
const stepsSanitized = sanitizeSteps(steps);
const notices = [];
if (widthSanitized.corrected || heightSanitized.corrected) {
notices.push(
`Resolution auto-corrected to ${widthSanitized.value}x${heightSanitized.value} (multiple of ${DIM_MULTIPLE}).`,
);
}
if (stepsSanitized.corrected) {
notices.push(`Steps clamped to ${stepsSanitized.value} (${STEPS_MIN}-${STEPS_MAX}).`);
}
if (state.editingId) {
const uniqueName = makeUniqueName(name, state.profiles, state.editingId);
state.profiles = state.profiles.map((profile) => {
if (profile.id !== state.editingId) {
return profile;
}
return {
...profile,
name: uniqueName,
width: widthSanitized.value,
height: heightSanitized.value,
steps: stepsSanitized.value,
};
});
state.selectedId = state.editingId;
} else {
const uniqueName = makeUniqueName(name, state.profiles);
const created = {
id: createId(),
name: uniqueName,
width: widthSanitized.value,
height: heightSanitized.value,
steps: stepsSanitized.value,
};
state.profiles = [...state.profiles, created];
state.selectedId = created.id;
}
persistState(node);
if (notices.length > 0) {
notify(notices.join(" "), "warn");
}
closeForm(node);
renderProfiles(node);
}
function bindFormEvents(node) {
const formState = node.__imageProfileForm;
if (!formState) {
return;
}
formState.presetSelect.addEventListener("change", () => {
if (formState.presetSelect.value !== CUSTOM_RESOLUTION_VALUE) {
formState.customResolutionInput.value = "";
}
});
formState.cancelButton.addEventListener("click", () => {
closeForm(node);
});
formState.saveButton.addEventListener("click", () => {
saveForm(node);
});
}
function mountManager(node) {
if (node.__imageProfileManagerMounted) {
syncStateFromWidgets(node);
return;
}
ensureStyles();
const root = document.createElement("div");
root.className = "cip-root";
const header = document.createElement("div");
header.className = "cip-header";
const title = document.createElement("div");
title.className = "cip-title";
title.textContent = "Image Profiles";
const addButton = document.createElement("button");
addButton.className = "cip-add";
addButton.type = "button";
addButton.textContent = "+ Add Profile";
header.append(title, addButton);
const list = document.createElement("div");
list.className = "cip-list";
root.append(header, list);
node.__imageProfileState = initializeState(node);
node.__imageProfileListEl = list;
node.__imageProfileForm = buildFormUI(root);
bindFormEvents(node);
addButton.addEventListener("click", () => {
openForm(node, "create");
});
node.addDOMWidget("image_profile_manager", "ImageProfileManager", root, {
hideOnZoom: false,
serialize: false,
getValue: () => "",
setValue: () => {},
});
persistState(node);
renderProfiles(node);
node.__imageProfileManagerMounted = true;
fitNode(node, { enforceDefaultMinimum: true });
setTimeout(() => fitNode(node, { enforceDefaultMinimum: true }), 0);
setTimeout(() => fitNode(node, { enforceDefaultMinimum: true }), 120);
setTimeout(() => fitNode(node, { enforceDefaultMinimum: true }), 260);
}
app.registerExtension({
name: EXTENSION_NAME,
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!TARGET_NODE_NAMES.has(nodeData?.name)) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onNodeCreatedImageProfile() {
onNodeCreated?.apply(this, arguments);
hideStateWidgets(this);
mountManager(this);
setTimeout(() => syncStateFromWidgets(this), 0);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function onConfigureImageProfile() {
const result = onConfigure?.apply(this, arguments);
hideStateWidgets(this);
syncStateFromWidgets(this);
return result;
};
},
});