Files
DemonGatanjieu-Anomalous_Mo…/web/modules/notebook_canvas.js
T
DemonGatanjieuandClaude Opus 5.5 074e9e43a0 Make Prompt Notes a Combos page with one Use button
A note was always a main model, LoRAs and a prompt kept together, so it is
now a combo (搭配) with its own rail page and Home card: a card per combo
with its model's cover, search and New; a card opens the editor, Back
returns to the list. Use puts the combo into the open workflow when the
canvas has a main model loader, builds a new group of nodes when it has
none, and asks when several loaders leave it unclear. The notes column,
quick-jump list and the Material Library's notes shelf are gone, with
their CSS and words; note files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:19:27 +08:00

189 lines
8.1 KiB
JavaScript

/**
* The active combo (搭配) on the canvas. Use puts it into the open workflow
* (notebook_apply.js) after showing what changes, or, when the canvas has no main model
* loader, builds a new group of nodes that follows the pointer until a click.
*/
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { recordCanvasStep } from './canvas_history.js';
import { applyNotePlan, planNoteApply } from './notebook_apply.js';
import { selectedMaterialNode } from './node_material_actions.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
const t = (key, params) => translate(key, params);
/** 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';
}
/** Makes `plan` (planNoteApply) after a confirmation listing each change. */
async function applyPlan(owner, plan) {
const say = ([key, params = {}]) => t(key, Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v === '' ? t('noteApplyNone') : v])));
const reasons = plan.skipped.map(item => t('noteApplySkipped', { reason: say(item) }));
if (!plan.lines.length) {
await anomalousAlert([t('noteApplyNothing'), ...reasons].join('\n'), t('noteApplyTitle'));
return;
}
const message = [...plan.lines.map(say), ...reasons, '', t('noteApplyUndoHint')].join('\n');
if (!await anomalousConfirm(message, t('noteApplyTitle'), { okLabel: t('noteApplyConfirm') })) return;
applyNotePlan(app, plan);
owner.nbPanel.style.display = 'none';
owner.close();
}
/**
* Use (用上): into the open workflow when the canvas has a main model loader; with none, a
* new group of nodes; with several and none selected, asks before making a new group.
*/
export async function useNotebook() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
const plan = planNoteApply(app, data, selectedMaterialNode(app));
if (!plan.loader && data.mainModel) {
const many = plan.skipped.find(([key]) => key === 'noteApplyManyLoaders');
if (many && !await anomalousConfirm(t('comboManyLoaders', many[1]), t('noteApplyTitle'), { okLabel: t('sendToCanvas') })) return;
this.sendNotebookToCanvas();
return;
}
await applyPlan(this, plan);
}
/** 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;
}
export function sendNotebookToCanvas() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
if (!data.mainModel) {
void anomalousAlert(t('notebookSelectMain'));
return;
}
const groupNodes = [];
const isUnet = isUnetModel(data.mainModel);
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
app.graph.add(ckptNode);
groupNodes.push({ node: ckptNode, relX: 0, relY: 0 });
const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename;
this.setWidgetValuePath(ckptNode, relPath);
let lastNode = ckptNode;
let lastModelSlot = 0;
let lastClipSlot = isUnet ? null : 1;
let relX = 350;
let relY = 0;
data.loras.forEach((lora, idx) => {
const loraNode = LiteGraph.createNode(isUnet ? "LoraLoaderModelOnly" : "LoraLoader");
app.graph.add(loraNode);
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename;
this.setWidgetValuePath(loraNode, lrelPath);
lastNode.connect(lastModelSlot, loraNode, 0);
if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1);
lastNode = loraNode;
lastModelSlot = 0;
if (!isUnet) lastClipSlot = 1;
relX += 350;
});
if (data.promptEn) {
const posNode = LiteGraph.createNode("CLIPTextEncode");
posNode.title = "CLIP Text Encode (Positive)";
app.graph.add(posNode);
groupNodes.push({ node: posNode, relX: relX, relY: 0 });
if (posNode.widgets && posNode.widgets.length > 0) {
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = data.promptEn;
}
const clipSource = lastClipSlot === null ? canvasClipSource(groupNodes.map(item => item.node)) : null;
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, posNode, 0);
} else if (clipSource) {
clipSource.connect(clipSource.outputs.findIndex(output => output.type === 'CLIP'), posNode, 0);
} else {
anomalousAlert(t('noteNeedsClip'));
}
const negNode = LiteGraph.createNode("CLIPTextEncode");
negNode.title = "CLIP Text Encode (Negative)";
app.graph.add(negNode);
groupNodes.push({ node: negNode, relX: relX, relY: 250 });
if (negNode.widgets && negNode.widgets.length > 0) {
const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = "text, watermark, ugly, bad anatomy";
}
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, negNode, 0);
} else if (clipSource) {
clipSource.connect(clipSource.outputs.findIndex(output => output.type === 'CLIP'), negNode, 0);
}
}
recordCanvasStep(app);
this.nbPanel.style.display = 'none';
this.close();
// Magnetic Sticking Logic
let isSticking = true;
const stickHandler = (e) => {
if (!isSticking || !app.canvas) return;
const canvas = app.canvas;
let canvasX, canvasY;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
canvasX = pos[0];
canvasY = pos[1];
} else {
const rect = canvas.canvas.getBoundingClientRect();
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
}
groupNodes.forEach(item => {
const w = (item.node.size && item.node.size[0]) ? item.node.size[0] : 200;
item.node.pos = [canvasX - (w / 2) + item.relX, canvasY - 20 + item.relY];
});
canvas.setDirty(true, true);
};
const dropHandler = (e) => {
if (!isSticking) return;
isSticking = false;
window.removeEventListener('mousemove', stickHandler, true);
window.removeEventListener('pointerdown', dropHandler, true);
window.removeEventListener('mousedown', dropHandler, true);
window.removeEventListener('click', dropHandler, true);
e.preventDefault();
e.stopPropagation();
};
window.addEventListener('mousemove', stickHandler, true);
setTimeout(() => {
window.addEventListener('pointerdown', dropHandler, true);
window.addEventListener('mousedown', dropHandler, true);
window.addEventListener('click', dropHandler, true);
}, 100);
}