Every page in both languages, all themes and at 480: - Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip). - Keep a task's link chip in its row, its x in reach, and colour the next-task chip. - Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines. - Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read. - Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so. - Right plurals in English, "AI" on the rail, one line left in Chinese translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
8.9 KiB
JavaScript
205 lines
8.9 KiB
JavaScript
/**
|
|
* Puts the active combo (搭配) on the canvas as a new group of nodes: its main model loader,
|
|
* its LoRAs in the LoRA node it chose (lora_nodes.js: ComfyUI's own chain, or rgthree's / Easy-Use's
|
|
* when installed; else ComfyUI's own, and it says so) and a positive and a negative prompt node (the positive holds the combo's
|
|
* prompt, the negative the common negative starter card), wired together. From a button the
|
|
* group follows the pointer until a click, and Esc takes it off again; a card dropped on the
|
|
* canvas builds it where it was dropped. Nothing already on the canvas changes, except that a
|
|
* UNet combo's prompt nodes take the canvas's CLIP loader when there is exactly one. A combo with
|
|
* its own node structure is put down the same way (combo_structure.js builds it).
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { translate } from './locales.js';
|
|
import { recordCanvasStep } from './canvas_history.js';
|
|
import { PROMPT_PRESETS } from './prompt_composition.js';
|
|
import { anomalousAlert } from './ui_dialog.js';
|
|
import { STRUCTURED } from './combo_slots.js';
|
|
import { buildStructure } from './combo_structure.js';
|
|
import { chainLoras, isInstalled, loraNodeKind } from './lora_nodes.js';
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
|
|
const NEGATIVE_PROMPT = PROMPT_PRESETS.find(card => card.id === 'preset_negative').content;
|
|
const STEP_X = 350;
|
|
|
|
/** Base-model value the backend uses for models whose metadata names no base model. */
|
|
export const UNLABELED_BASE_MODEL = '__unlabeled__';
|
|
|
|
/** UNet / diffusion models carry no text encoder, so their LoRAs patch the model only. */
|
|
export function isUnetModel(model) {
|
|
return model?.type === 'unet' || model?.type === 'diffusion_models';
|
|
}
|
|
|
|
/** The one text-encoder output already on the canvas (a loader), for a UNet note's prompts. */
|
|
function canvasClipSource(exclude) {
|
|
const sources = (app.graph?._nodes || []).filter(node => !exclude.includes(node)
|
|
&& (node.outputs || []).some(output => output.type === 'CLIP')
|
|
&& !(node.inputs || []).some(input => input.type === 'CLIP' || input.type === 'MODEL'));
|
|
return sources.length === 1 ? sources[0] : null;
|
|
}
|
|
|
|
const modelPath = (model) => {
|
|
const sub = String(model.subfolder || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
return sub ? `${sub}/${model.filename}` : model.filename;
|
|
};
|
|
|
|
/** The pointer's position in canvas coordinates. */
|
|
export function canvasPoint(event) {
|
|
const canvas = app.canvas;
|
|
if (canvas.convertEventToCanvasOffset) return canvas.convertEventToCanvasOffset(event);
|
|
const rect = canvas.canvas.getBoundingClientRect();
|
|
return [
|
|
(event.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale,
|
|
(event.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The group follows the pointer until a click puts it down (then `placed()`); Esc removes it
|
|
* again. While it follows, `owner.placingCombo` holds back the activity log, which is told once
|
|
* the group is down (after Esc there is nothing to log).
|
|
*/
|
|
export function followPointer(owner, groupNodes, placed) {
|
|
const half = (groupNodes[0].node.size?.[0] || 200) / 2;
|
|
const move = (event) => {
|
|
if (!app.canvas) return;
|
|
const [x, y] = canvasPoint(event);
|
|
groupNodes.forEach(item => { item.node.pos = [x - half + item.relX, y - 20 + item.relY]; });
|
|
app.canvas.setDirty(true, true);
|
|
};
|
|
let armTimer = 0;
|
|
const stop = (event) => {
|
|
owner.placingCombo = false;
|
|
clearTimeout(armTimer);
|
|
window.removeEventListener('mousemove', move, true);
|
|
window.removeEventListener('keydown', cancel, true);
|
|
['pointerdown', 'mousedown', 'click'].forEach(type => window.removeEventListener(type, drop, true));
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
const drop = (event) => {
|
|
stop(event);
|
|
placed();
|
|
owner.flushCanvasActivity?.();
|
|
};
|
|
const cancel = (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
stop(event);
|
|
groupNodes.forEach(item => app.graph.remove(item.node));
|
|
app.graph.setDirtyCanvas?.(true, true);
|
|
};
|
|
|
|
owner.placingCombo = true;
|
|
window.addEventListener('mousemove', move, true);
|
|
window.addEventListener('keydown', cancel, true);
|
|
// Later than the click that pressed the button, so that click does not put the group down.
|
|
armTimer = setTimeout(() => {
|
|
['pointerdown', 'mousedown', 'click'].forEach(type => window.addEventListener(type, drop, true));
|
|
}, 100);
|
|
}
|
|
|
|
/** What went wrong putting a structure down, for the alert after it is placed. */
|
|
function problemLines(problems) {
|
|
return problems.map(problem => (problem.kind === 'slot_missing'
|
|
? t('comboPlaceSlotMissing', { label: problem.label, widget: problem.widget, node: problem.type })
|
|
: t('comboPlaceLinkMissing', { from: problem.from, to: problem.to, type: problem.type }))).join('\n');
|
|
}
|
|
|
|
/** A combo with a node structure: its nodes and links, its slots filled (combo_structure.js). */
|
|
function placeStructure(owner, data, position) {
|
|
let built;
|
|
try {
|
|
built = buildStructure(data.structure, data.values);
|
|
} catch (error) {
|
|
if (error.code !== 'missing_nodes') throw error;
|
|
void anomalousAlert(t('comboPlaceMissingNodes', {
|
|
nodes: error.missing.map(item => (item.pack ? `${item.type} (${item.pack})` : item.type)).join(t('listSeparator')),
|
|
}));
|
|
return;
|
|
}
|
|
if (!built.made.length) return;
|
|
const placed = () => {
|
|
recordCanvasStep(app);
|
|
if (built.problems.length) void anomalousAlert(`${t('comboPlaceProblems')}\n${problemLines(built.problems)}`);
|
|
};
|
|
// Only the browser closes: the combo stays open in it, so it opens again where it was
|
|
// (hiding the combo page here left the browser blank the next time it opened).
|
|
if (!position) {
|
|
followPointer(owner, built.made, placed);
|
|
owner.close();
|
|
return;
|
|
}
|
|
owner.close();
|
|
built.made.forEach(item => { item.node.pos = [position[0] + item.relX, position[1] + item.relY]; });
|
|
app.graph.setDirtyCanvas?.(true, true);
|
|
placed();
|
|
}
|
|
|
|
/** Builds the combo as a new group of nodes: at `position` (canvas coordinates), or following the pointer. */
|
|
export function sendNotebookToCanvas(position = null) {
|
|
if (!this.currentNotebook) return;
|
|
const data = this.currentNotebook.data || {};
|
|
if (data.kind === STRUCTURED) {
|
|
placeStructure(this, data, position);
|
|
return;
|
|
}
|
|
if (!data.mainModel) {
|
|
void anomalousAlert(t('notebookSelectMain'));
|
|
return;
|
|
}
|
|
|
|
const groupNodes = [];
|
|
const add = (type, relX, relY) => {
|
|
const node = LiteGraph.createNode(type);
|
|
app.graph.add(node);
|
|
groupNodes.push({ node, relX, relY });
|
|
return node;
|
|
};
|
|
const isUnet = isUnetModel(data.mainModel);
|
|
|
|
const loader = add(isUnet ? 'UNETLoader' : 'CheckpointLoaderSimple', 0, 0);
|
|
this.setWidgetValuePath(loader, modelPath(data.mainModel));
|
|
const wanted = loraNodeKind(data.loraNode);
|
|
const kind = isInstalled(wanted) ? wanted : loraNodeKind('core');
|
|
const chained = chainLoras(kind, {
|
|
add, source: loader, clipSlot: isUnet ? null : 1, loras: (data.loras || []).map(modelPath), relX: STEP_X, step: STEP_X,
|
|
setPath: (node, path) => this.setWidgetValuePath(node, path),
|
|
});
|
|
const { last, clipSlot } = chained;
|
|
const relX = chained.relX + Math.max(STEP_X, (last.size?.[0] || 0) + 50); // a Power Lora Loader is wider than the step
|
|
|
|
const promptNode = (title, text, relY) => {
|
|
const node = add('CLIPTextEncode', relX, relY);
|
|
node.title = title;
|
|
const box = node.widgets?.find(w => w.name === 'text' || w.type === 'customtext');
|
|
if (box) box.value = text;
|
|
return node;
|
|
};
|
|
const prompts = [
|
|
promptNode('CLIP Text Encode (Positive)', String(data.promptEn || ''), 0),
|
|
promptNode('CLIP Text Encode (Negative)', NEGATIVE_PROMPT, 250),
|
|
];
|
|
let clip = clipSlot === null ? null : { node: last, slot: clipSlot };
|
|
if (!clip) {
|
|
const source = canvasClipSource(groupNodes.map(item => item.node));
|
|
if (source) clip = { node: source, slot: source.outputs.findIndex(output => output.type === 'CLIP') };
|
|
}
|
|
if (clip) prompts.forEach(node => clip.node.connect(clip.slot, node, 0));
|
|
|
|
const placed = () => {
|
|
recordCanvasStep(app);
|
|
const notes = [kind !== wanted && t('comboLoraNodeFallback', { node: t(wanted.labelKey), pack: wanted.pack }), !clip && t('noteNeedsClip')];
|
|
if (notes.some(Boolean)) void anomalousAlert(notes.filter(Boolean).join('\n\n'));
|
|
};
|
|
if (!position) {
|
|
followPointer(this, groupNodes, placed); // first, so closing does not log the group yet
|
|
this.close();
|
|
return;
|
|
}
|
|
this.close();
|
|
groupNodes.forEach(item => { item.node.pos = [position[0] + item.relX, position[1] + item.relY]; });
|
|
app.graph.setDirtyCanvas?.(true, true);
|
|
placed();
|
|
}
|