Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_material_application.js
T
DemonGatanjieuandClaude Opus 5.5 5492d4a5e1 feat(undo): every canvas write is a Ctrl+Z step; undo from the toast and the activity log
ComfyUI takes undo steps on mouse-up and key-up; a drag-and-drop ends in
neither, and writes made in click handlers or after a fetch landed in no
step, so Ctrl+Z could undo the step before instead. canvas_history.js asks
for a step after each write (applied values, drops, created or inserted
nodes, recipes, prompt notes).

A drop's receipt also pops up at the top right with Undo (shared with the
receipt's own button); prompt nodes made by a drop on empty canvas can be
undone there. canvas_undo.js keeps what each canvas activity entry did, in
full, while the page is open; the log offers Undo while nothing changed it
since. Entries that removed nodes or rewired existing ones, seeds, and
choices this computer no longer has are never offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:14 +08:00

339 lines
18 KiB
JavaScript

import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { anomalousAlert } from './ui_dialog.js';
import { escapeHtml } from './safe_dom.js';
import { applyPromptRolesToBlocks, materialNodeHeading } from './material_inspector.js';
import {
applyMaterialBlock,
applyNodeMaterialValues,
selectedMaterialNode,
extractMaterialPromptEnvelope,
fillPrompt,
isModelFilePath,
} from './node_material_actions.js';
import { partnerBox, promptBoxes, typeTakesPrompt } from './prompt_boxes.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { recordCanvasStep } from './canvas_history.js';
/** The receipt with its undo; `toast`: also pop it up (with the same undo) for a drop on the canvas. */
export function showMaterialApplication(parent, result, node, { toast = '' } = {}) {
parent.querySelector('.anomalous-material-application-result')?.remove();
const receipt = text(parent, 'div', '', 'anomalous-material-application-result');
receipt.setAttribute('role', 'status');
const status = text(receipt, 'span', node ? t('materialAppliedTarget', { name: materialNodeHeading(node), id: node.id }) : t('materialNodeApplied'));
const undo = text(receipt, 'button', t('materialUndo'), 'anomalous-btn-ghost');
undo.type = 'button';
let undone = false;
const runUndo = () => {
if (undone) return;
try { result.undo(); undone = true; status.textContent = t('materialUndone'); undo.remove(); }
catch (error) { status.textContent = t(error.message); if (toast) showWorkbenchToast(t(error.message)); }
};
undo.onclick = runUndo;
if (toast) showWorkbenchToast(toast, { label: t('activityUndo'), run: runUndo });
}
/** Options that make a drop's receipt pop up by the canvas too. */
const dropReceipt = (droppedNode, node) => (droppedNode
? { toast: t('materialAppliedTarget', { name: materialNodeHeading(node), id: node.id }) } : {});
export function applyMaterialToSelectedNode(node, block, hashes, parent, receipt = {}) {
if (selectedMaterialNode(app) !== node) throw new Error('materialTargetChanged');
return applyMaterialToNode(node, block, hashes, parent, receipt);
}
export function applyMaterialToNode(node, block, hashes, parent, receipt = {}) {
const result = applyMaterialBlock(app, node, block, hashes);
showMaterialApplication(parent, result, node, receipt);
return result;
}
export async function fetchMaterial(filename, options = {}) {
const query = new URLSearchParams({ filename, include_workflow: options.includeWorkflow ? '1' : '0' });
const response = await fetch(`/anomalous/material_full?${query}`, {
cache: 'no-store',
signal: options.signal,
});
const payload = await jsonResponse(response, 'material load failed');
if (payload.status !== 'success') throw new Error(payload.message || 'material load failed');
return payload;
}
export function updateMaterialContext(owner) {
const node = selectedMaterialNode(app);
owner.materialTarget = node;
if (!node) owner.materialApplyMode = false;
if (!owner.materialContext) return;
owner.materialContext.replaceChildren();
text(owner.materialContext, 'strong', node
? t(owner.materialApplyMode ? 'materialApplyingTo' : 'materialSelectedTarget', { name: materialNodeHeading(node), id: node.id })
: t('materialSelectOneNode'));
if (node) {
const toggle = text(owner.materialContext, 'button', t(owner.materialApplyMode ? 'materialBrowseAll' : 'materialShowCompatible'), 'anomalous-btn-ghost');
toggle.onclick = () => {
owner.materialApplyMode = !owner.materialApplyMode;
owner.materialKind = '';
owner.materialKindCategory = 'all';
if (owner.materialKindPills) {
for (const p of Object.values(owner.materialKindPills)) p.classList.remove('is-active');
owner.materialKindPills.all?.classList.add('is-active');
}
if (owner.materialKindInput) owner.materialKindInput.value = '';
owner.refreshMaterials(1);
};
}
text(owner.materialContext, 'small', node
? t('materialApplyContextHint')
: (t('materialDragGlobalHint') || '💡 提示:按住卡片直接拖到画布节点上注入参数,拖到空白处载入工作流'));
}
export function watchMaterialSelection(owner, showMaterialDetail) {
if (owner.materialSelectionHooked || !app.canvas) return;
owner.materialSelectionHooked = true;
window.addEventListener('anomalous-language-change', async () => {
if (owner.nbPanel?.style.display !== 'flex' || owner.materialView?.style.display !== 'flex') return;
const detail = owner.materialOpenedDetail;
await owner.showMaterials();
if (detail) await showMaterialDetail(owner, detail);
});
let scheduled = false;
for (const key of ['onNodeSelected', 'onNodeDeselected']) {
const previous = app.canvas[key];
app.canvas[key] = function (...args) {
const result = previous?.apply(this, args);
if (!scheduled) {
scheduled = true;
queueMicrotask(() => {
scheduled = false;
if (owner.nbPanel?.style.display === 'none' || owner.materialView?.style.display !== 'flex') return;
const target = selectedMaterialNode(app);
if (target !== owner.materialTarget) {
owner.materialTarget = target;
owner.materialApplyMode = !!target && !(owner.materialKindCategory === 'prompts' || ['prompt_plan', 'prompt_text', 'prompt_note_bundle'].includes(owner.materialKind));
updateMaterialContext(owner);
if (!owner.materialDetailView) owner.refreshMaterials(1);
}
});
}
return result;
};
}
}
/**
* A prompt dropped on one box: that box takes its role's text, and the opposite-role box
* on the same sampler takes the other text when the material has it. One undo for both.
*/
function fillDroppedBox(owner, material, payload, node, box) {
if (node.widgets?.[box.index] !== box.widget) throw new Error('materialTargetChanged');
const envelope = extractMaterialPromptEnvelope(material, payload);
const results = [fillPrompt(app, node, envelope, box, { record: false })];
const other = { positive: 'negative', negative: 'positive' }[box.role];
let partner = other && envelope[box.role] && envelope[other] ? partnerBox(node, box.role) : null;
if (partner) {
try { results.push(fillPrompt(app, partner.node, envelope, partner.box, { record: false })); }
catch (error) { partner = null; }
}
recordCanvasStep(app);
const result = { undo() { for (const item of [...results].reverse()) item.undo(); recordCanvasStep(app); } };
showMaterialApplication(owner.materialContext, result, node, { toast: partner
? t('promptFilledTwo', { node: `#${node.id}`, other: `#${partner.node.id}` })
: t('promptFilledOne', { node: `#${node.id}` }) });
return result;
}
/** Applies a material to `droppedNode` or the selected node; `box`: the prompt box it was dropped on. */
export async function applyLibraryMaterial(owner, material, droppedNode = null, graph = app.graph, { box = null } = {}) {
const node = droppedNode || selectedMaterialNode(app);
if (!node) { await anomalousAlert(t('materialTargetChanged')); return; }
if (owner.materialApplying) return;
owner.materialApplying = true;
try {
const payload = await fetchMaterial(material.filename);
if (app.graph !== graph || graph.getNodeById(node.id) !== node || (!droppedNode && selectedMaterialNode(app) !== node)) throw new Error('materialTargetChanged');
if (owner.nbPanel?.style.display !== 'flex' || owner.materialView?.style.display !== 'flex'
|| (owner.modal && !owner.modal.classList.contains('visible'))) return;
if (box) return fillDroppedBox(owner, material, payload, node, box);
// Path 1: Strict same-type block application
const sameTypeBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
.filter(block => block.type === node.type && block.widgets_values?.length);
if (sameTypeBlocks.length === 1) {
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
return apply(node, sameTypeBlocks[0], payload.workflow_hashes, owner.materialContext, dropReceipt(droppedNode, node));
}
if (sameTypeBlocks.length > 1) {
openMaterialChoiceDialog(owner, node, sameTypeBlocks, payload, droppedNode, graph);
return;
}
// Path 2: the material's prompt text into the node's prompt boxes.
const boxes = promptBoxes(node);
if (!boxes.length) throw new Error('materialNoCompatibleValues');
const envelope = extractMaterialPromptEnvelope(material, payload);
if (!envelope.hasPrompt) throw new Error('materialNoCompatibleValues');
const promptBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
.filter(b => {
const isPrompt = b.promptRole === 'positive' || b.promptRole === 'negative' || b.promptRole === 'both' || typeTakesPrompt(b.type);
return isPrompt && Array.isArray(b.widgets_values) && b.widgets_values.some(v => typeof v === 'string' && v.trim() && !isModelFilePath(v));
});
// Boxes nothing tells the role of: the user picks which of the material's prompts.
if (boxes.every(box => !box.role) && promptBlocks.length > 1) {
openMaterialChoiceDialog(owner, node, promptBlocks, payload, droppedNode, graph);
return;
}
const result = fillPrompt(app, node, envelope);
showMaterialApplication(owner.materialContext, result, node, dropReceipt(droppedNode, node));
return result;
} catch (error) {
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
} finally {
owner.materialApplying = false;
}
}
function openMaterialChoiceDialog(owner, node, blocks, payload, droppedNode, graph) {
const applyTarget = (targetBlock) => {
if (targetBlock.type === node.type) {
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
return apply(node, targetBlock, payload.workflow_hashes, owner.materialContext, dropReceipt(droppedNode, node));
}
if (selectedMaterialNode(app) !== node && !droppedNode) throw new Error('materialTargetChanged');
const boxes = promptBoxes(node);
let textVal = '';
if (Array.isArray(targetBlock.widgets_values)) {
for (const v of targetBlock.widgets_values) {
if (typeof v === 'string' && v.trim() && !isModelFilePath(v)) {
textVal = v.trim();
break;
}
}
}
if (!textVal) throw new Error('materialNoCompatibleValues');
// Never write a block into the opposite role's box.
const role = targetBlock.promptRole;
const targetWidget = role === 'positive' || role === 'negative'
? boxes.find(box => box.role === role) || boxes.find(box => !box.role)
: boxes.find(box => !box.role) || boxes[0];
if (!targetWidget) throw new Error('materialNoCompatibleValues');
const result = applyNodeMaterialValues(app, node, [{ index: targetWidget.index, value: textVal }], {
sourceNodeId: targetBlock.node_id,
workflowHashes: payload.workflow_hashes,
});
showMaterialApplication(owner.materialContext, result, node, dropReceipt(droppedNode, node));
return result;
};
owner.materialBlockDialog?.close();
const dialog = document.createElement('dialog');
dialog.className = 'anomalous-material-choice';
owner.materialBlockDialog = dialog;
text(dialog, 'h3', t('materialChooseBlock'));
const status = text(dialog, 'p', '');
status.setAttribute('role', 'alert');
const list = document.createElement('div');
list.className = 'anomalous-material-choice-list';
list.style.cssText = 'display:flex;flex-direction:column;gap:12px;margin:14px 0;max-height:60vh;overflow-y:auto;padding-right:4px;';
dialog.appendChild(list);
for (const block of blocks) {
const itemCard = document.createElement('div');
itemCard.className = 'anomalous-material-choice-card';
itemCard.style.cssText = 'background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px;transition:border-color 0.2s;';
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
const roleBadge = {
positive: `[🟢 ${t('recipePromptRolePositive') || '正向'}] `,
negative: `[🔴 ${t('recipePromptRoleNegative') || '负向'}] `,
both: `[🟣 ${t('recipePromptRoleBoth') || '混合'}] `,
}[block.promptRole] || '';
const heading = document.createElement('div');
heading.style.cssText = 'font-weight:700;font-size:13px;color:#f3f4f6;display:flex;align-items:center;gap:6px;';
heading.innerHTML = `${roleBadge}<span>${escapeHtml(materialNodeHeading(block))} <small style="color:#9ca3af;font-size:11px;">#${block.node_id}</small></span>`;
headerRow.appendChild(heading);
itemCard.appendChild(headerRow);
let textContent = '';
if (Array.isArray(block.widgets_values)) {
for (const val of block.widgets_values) {
if (typeof val === 'string' && val.trim().length > 0 && !isModelFilePath(val)) {
textContent = val.trim();
break;
}
}
}
if (textContent) {
const previewBox = document.createElement('div');
previewBox.className = 'anomalous-material-snippet-box';
previewBox.style.cssText = 'background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:8px 10px;font-size:11px;color:#d1d5db;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow:hidden;position:relative;transition:max-height 0.25s ease;';
const textSpan = document.createElement('span');
textSpan.textContent = textContent;
previewBox.appendChild(textSpan);
itemCard.appendChild(previewBox);
if (textContent.length > 80 || textContent.includes('\n')) {
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.style.cssText = 'background:none;border:none;color:#60a5fa;cursor:pointer;font-size:11px;padding:2px 0;align-self:flex-start;text-decoration:underline;';
toggleBtn.textContent = t('expandText') || '展开全部 ▾';
let expanded = false;
toggleBtn.onclick = (e) => {
e.stopPropagation();
expanded = !expanded;
if (expanded) {
previewBox.style.maxHeight = '240px';
previewBox.style.overflowY = 'auto';
toggleBtn.textContent = t('collapseText') || '收起 ▴';
} else {
previewBox.style.maxHeight = '80px';
previewBox.style.overflow = 'hidden';
toggleBtn.textContent = t('expandText') || '展开全部 ▾';
}
};
itemCard.appendChild(toggleBtn);
}
} else if (Array.isArray(block.widgets_values) && block.widgets_values.length > 0) {
const paramsSummary = document.createElement('div');
paramsSummary.style.cssText = 'font-size:11px;color:#9ca3af;background:rgba(0,0,0,0.2);padding:6px 8px;border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
paramsSummary.textContent = block.widgets_values.slice(0, 5).join(' · ');
itemCard.appendChild(paramsSummary);
}
const choose = document.createElement('button');
choose.className = 'anomalous-btn-primary';
choose.style.cssText = 'align-self:flex-end;padding:7px 14px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;cursor:pointer;border-radius:6px;';
choose.textContent = t('materialUseBlock') || (textContent ? '选用此段文本 ➔' : '应用此组参数 ➔');
choose.onclick = async () => {
try {
if (app.graph !== graph) throw new Error('materialTargetChanged');
applyTarget(block);
dialog.close();
} catch (error) {
status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message);
}
};
itemCard.appendChild(choose);
list.appendChild(itemCard);
}
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost');
close.style.cssText = 'margin-top:10px;align-self:flex-end;';
close.onclick = () => dialog.close();
dialog.onclose = () => { dialog.remove(); if (owner.materialBlockDialog === dialog) owner.materialBlockDialog = null; };
document.body.appendChild(dialog);
dialog.showModal();
}