ui_tts_import.js gives its frame and toolbar to ui_tts_import_screens.js and its window drop to ui_tts_file_drop.js; material_inspector.js gives the PNG chunk reader to png_metadata.js. 221 texts no code uses any more leave locales.js (both languages). No behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
25 KiB
JavaScript
569 lines
25 KiB
JavaScript
import { text, jsonResponse } from './ui_dom.js';
|
||
/** Shared image metadata and node-parameter inspection for the library and workbench. */
|
||
import { translate } from './locales.js';
|
||
const t = (key, params) => translate(key, params);
|
||
|
||
export function fileBaseName(value) {
|
||
return String(value || '').replace(/\\/g, '/').split('/').pop();
|
||
}
|
||
|
||
function previewIsVideo(url) {
|
||
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
|
||
}
|
||
|
||
export function modelCustomNotes(model) {
|
||
return String(model?.metadata?.custom_notes || '').trim();
|
||
}
|
||
|
||
function bindHoverPreviewVideo(video) {
|
||
video.muted = true;
|
||
video.loop = true;
|
||
video.playsInline = true;
|
||
video.preload = 'metadata';
|
||
video.onpointerenter = () => video.play().catch(() => {});
|
||
video.onpointerleave = () => {
|
||
video.pause();
|
||
video.currentTime = 0;
|
||
};
|
||
}
|
||
|
||
export function appendLocalPreview(parent, url, className) {
|
||
if (!url) return null;
|
||
const wrap = document.createElement('div');
|
||
wrap.className = className;
|
||
if (previewIsVideo(url)) {
|
||
const video = document.createElement('video');
|
||
video.src = url;
|
||
bindHoverPreviewVideo(video);
|
||
wrap.appendChild(video);
|
||
} else {
|
||
const image = document.createElement('img');
|
||
image.src = url;
|
||
image.alt = '';
|
||
image.loading = 'lazy';
|
||
wrap.appendChild(image);
|
||
}
|
||
parent.appendChild(wrap);
|
||
return wrap;
|
||
}
|
||
|
||
export async function resolveLocalModels(paths) {
|
||
const unique = [...new Set((paths || []).filter(Boolean))];
|
||
if (!unique.length) return {};
|
||
const response = await fetch('/anomalous/resolve_paths_to_previews', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paths: unique, exact_only: false }),
|
||
});
|
||
const payload = await jsonResponse(response, 'preview resolve failed');
|
||
return payload.models || {};
|
||
}
|
||
|
||
export function lookupLocalModel(localModels, path) {
|
||
if (!path || !localModels) return null;
|
||
return localModels[path] || localModels[fileBaseName(path)] || null;
|
||
}
|
||
|
||
/**
|
||
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
|
||
* directly from the workflow.
|
||
*/
|
||
export function extractWorkflowDetails(workflow) {
|
||
const params = {};
|
||
const positivePrompts = [];
|
||
const negativePrompts = [];
|
||
const allPrompts = [];
|
||
const loraDetailsMap = new Map();
|
||
const discoveredModels = {
|
||
checkpoints: [],
|
||
loras: [],
|
||
textEncoders: [],
|
||
vaes: [],
|
||
others: [],
|
||
};
|
||
|
||
if (!workflow || typeof workflow !== 'object') {
|
||
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
|
||
}
|
||
|
||
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
||
|
||
for (const node of nodes) {
|
||
if (!node || typeof node !== 'object') continue;
|
||
const ntype = String(node.type || '').trim();
|
||
const ntypeLower = ntype.toLowerCase();
|
||
const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : [];
|
||
|
||
// 1. Sampling parameters
|
||
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
|
||
const isAdv = ntypeLower === 'ksampleradvanced';
|
||
const offset = isAdv ? 1 : 0;
|
||
if (widgets[offset] != null && params.seed == null) params.seed = widgets[offset];
|
||
if (widgets[2 + offset] != null && params.steps == null) params.steps = widgets[2 + offset];
|
||
if (widgets[3 + offset] != null && params.cfg == null) params.cfg = widgets[3 + offset];
|
||
if (widgets[4 + offset] != null && params.sampler_name == null) params.sampler_name = widgets[4 + offset];
|
||
if (widgets[5 + offset] != null && params.scheduler == null) params.scheduler = widgets[5 + offset];
|
||
if (widgets[6 + offset] != null && params.denoise == null) params.denoise = widgets[6 + offset];
|
||
} else if (ntypeLower === 'emptylatentimage') {
|
||
if (widgets[0] && widgets[1] && params.resolution == null) {
|
||
params.resolution = `${widgets[0]} × ${widgets[1]}`;
|
||
}
|
||
}
|
||
|
||
// 2. Full un-truncated prompts
|
||
if (ntypeLower.includes('cliptextencode') || ntypeLower.includes('prompt')) {
|
||
const nodeTitle = String(node.title || ntype).toLowerCase();
|
||
for (const val of widgets) {
|
||
if (typeof val === 'string' && val.trim()) {
|
||
const textVal = val.trim();
|
||
if (!allPrompts.includes(textVal)) allPrompts.push(textVal);
|
||
if (nodeTitle.includes('neg') || nodeTitle.includes('负向')) {
|
||
if (!negativePrompts.includes(textVal)) negativePrompts.push(textVal);
|
||
} else {
|
||
if (!positivePrompts.includes(textVal)) positivePrompts.push(textVal);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Categorized model references & LoRA weights
|
||
if (ntypeLower.includes('checkpoint') || ntypeLower === 'unetloader' || ntypeLower.includes('diffusionmodel')) {
|
||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||
const name = widgets[0];
|
||
if (!discoveredModels.checkpoints.some(c => c.name === name)) {
|
||
discoveredModels.checkpoints.push({
|
||
name,
|
||
category: ntypeLower.includes('unet') ? 'unet' : 'checkpoint',
|
||
});
|
||
}
|
||
}
|
||
} else if (ntypeLower.includes('lora')) {
|
||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||
const name = widgets[0];
|
||
const strengthModel = widgets[1] != null ? Number(widgets[1]) : 1.0;
|
||
const strengthClip = widgets[2] != null ? Number(widgets[2]) : strengthModel;
|
||
loraDetailsMap.set(name, { strengthModel, strengthClip });
|
||
const baseName = name.replace(/\\/g, '/').split('/').pop();
|
||
loraDetailsMap.set(baseName, { strengthModel, strengthClip });
|
||
if (!discoveredModels.loras.some(l => l.name === name)) {
|
||
discoveredModels.loras.push({ name, strengthModel, strengthClip, category: 'lora' });
|
||
}
|
||
}
|
||
} else if (ntypeLower.includes('vaeloader') || (ntypeLower.includes('vae') && !ntypeLower.includes('encode') && !ntypeLower.includes('decode'))) {
|
||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||
const name = widgets[0];
|
||
if (!discoveredModels.vaes.some(v => v.name === name)) {
|
||
discoveredModels.vaes.push({ name, category: 'vae' });
|
||
}
|
||
}
|
||
} else if (ntypeLower.includes('cliploader') || ntypeLower.includes('dualcliploader') || ntypeLower.includes('textencoder')) {
|
||
for (const w of widgets) {
|
||
if (typeof w === 'string' && (w.endsWith('.safetensors') || w.endsWith('.bin') || w.endsWith('.pt') || w.endsWith('.sft'))) {
|
||
if (!discoveredModels.textEncoders.some(t => t.name === w)) {
|
||
discoveredModels.textEncoders.push({ name: w, category: 'text_encoder' });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
|
||
}
|
||
|
||
export function detailedBlocksFromWorkflow(workflow, serverBlocks = []) {
|
||
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
|
||
if (!nodes.length) return Array.isArray(serverBlocks) ? serverBlocks : [];
|
||
const serverById = new Map((serverBlocks || []).map(block => [String(block?.node_id), block]));
|
||
const occurrences = {};
|
||
return nodes.filter(node => node && typeof node === 'object' && node.type).map(node => {
|
||
const nodeType = String(node.type);
|
||
occurrences[nodeType] = (occurrences[nodeType] || 0) + 1;
|
||
const server = serverById.get(String(node.id)) || {};
|
||
const lower = nodeType.toLowerCase();
|
||
const volatile = lower === 'ksampler' ? [0] : lower === 'ksampleradvanced' ? [1] : [];
|
||
return {
|
||
node_id: node.id,
|
||
type: nodeType,
|
||
title: node.title || server.title || nodeType,
|
||
occurrence: occurrences[nodeType],
|
||
widget_count: Array.isArray(node.widgets_values) ? node.widgets_values.length : 0,
|
||
widgets_values: Array.isArray(node.widgets_values) ? node.widgets_values : [],
|
||
volatile_widget_indexes: Array.isArray(server.volatile_widget_indexes)
|
||
? server.volatile_widget_indexes
|
||
: volatile,
|
||
properties: node.properties && typeof node.properties === 'object' ? node.properties : {},
|
||
mode: node.mode,
|
||
};
|
||
});
|
||
}
|
||
|
||
function materialWidgetLabels(nodeType) {
|
||
const type = String(nodeType || '').toLowerCase();
|
||
if (type === 'ksampler') return [
|
||
t('materialParamSeed'), t('materialParamSeedControl'), t('recipeCardSpecsSteps'),
|
||
'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'), t('materialDenoise'),
|
||
];
|
||
if (type === 'ksampleradvanced') return [
|
||
t('materialParamAddNoise'), t('materialParamSeed'), t('materialParamSeedControl'),
|
||
t('recipeCardSpecsSteps'), 'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'),
|
||
t('materialParamStartStep'), t('materialParamEndStep'), t('materialParamLeftoverNoise'),
|
||
];
|
||
if (type === 'emptylatentimage') return [t('materialParamWidth'), t('materialParamHeight'), t('materialParamBatchSize')];
|
||
if (/checkpointloader(simple)?$/.test(type)) return [t('materialParamCheckpoint')];
|
||
if (type.endsWith('unetloader')) return [t('materialParamUnet'), t('materialParamWeightDtype')];
|
||
if (type.includes('loraloader')) return [t('materialParamLora'), t('materialParamModelStrength'), t('materialParamClipStrength')];
|
||
if (type.endsWith('vaeloader')) return [t('materialParamVae')];
|
||
if (type.includes('cliptextencode')) return [t('materialPromptText')];
|
||
if (type.endsWith('clipvisionloader')) return [t('materialParamClipVision')];
|
||
if (type.endsWith('controlnetloader')) return [t('materialParamControlNet')];
|
||
if (type.endsWith('dualcliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipType')];
|
||
if (type.endsWith('triplecliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipThree')];
|
||
if (type.endsWith('cliploader')) return [t('materialParamClipOne'), t('materialParamClipType')];
|
||
if (type === 'saveimage') return [t('materialParamFilenamePrefix')];
|
||
return [];
|
||
}
|
||
|
||
const comfyWidgetLabelCache = new Map();
|
||
|
||
function comfyNodeTitle(nodeType) {
|
||
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
|
||
return (ctor && ctor.title) || nodeType;
|
||
}
|
||
|
||
export function comfyWidgetLabels(nodeType) { // widget labels in order (ComfyUI's, else known ones)
|
||
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
|
||
let labels = [];
|
||
try {
|
||
const node = globalThis.LiteGraph?.createNode?.(nodeType);
|
||
if (node?.widgets?.length) labels = node.widgets.map(widget => widget.label || widget.name || '');
|
||
} catch (error) { /* some node types cannot be instantiated off-canvas */ }
|
||
if (!labels.length) labels = materialWidgetLabels(nodeType);
|
||
comfyWidgetLabelCache.set(nodeType, labels);
|
||
return labels;
|
||
}
|
||
|
||
const NODE_TITLE_SUFFIX = /\s*(\((?:negative|positive|neg|pos)\)|((?:负向|正向|反向))|#\d+)\s*$/i;
|
||
|
||
function compactNodeToken(value) {
|
||
return String(value || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '');
|
||
}
|
||
|
||
function comfyNodeDefaultTitles(nodeType) {
|
||
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
|
||
const data = ctor?.nodeData;
|
||
return [nodeType, ctor?.title, ctor?.comfyClass, data?.name, data?.display_name].filter(Boolean);
|
||
}
|
||
|
||
export function materialNodeHeading(block) {
|
||
const type = String(block?.type || '');
|
||
const saved = String(block?.title || '').trim();
|
||
const localized = comfyNodeTitle(type) || type;
|
||
const suffixMatch = saved.match(NODE_TITLE_SUFFIX);
|
||
const suffix = suffixMatch ? ` ${suffixMatch[1]}` : '';
|
||
if (!saved || saved === type) return localized + suffix;
|
||
|
||
const core = saved.replace(NODE_TITLE_SUFFIX, '').trim();
|
||
const compactCore = compactNodeToken(core);
|
||
const isDefault = [type, localized, ...comfyNodeDefaultTitles(type)].some(name => {
|
||
const compactName = compactNodeToken(name);
|
||
return core === name || compactCore === compactName || (compactName && compactCore.startsWith(compactName));
|
||
});
|
||
return isDefault ? localized + suffix : saved;
|
||
}
|
||
|
||
function formatMaterialParameterValue(value) {
|
||
if (value === null) return 'null';
|
||
if (value === undefined) return 'undefined';
|
||
if (typeof value === 'string') return value;
|
||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
|
||
}
|
||
|
||
function isPromptBlock(block) {
|
||
const type = String(block?.type || '').toLowerCase();
|
||
return (type.includes('cliptextencode') || type.includes('prompt')) &&
|
||
Array.isArray(block.widgets_values) &&
|
||
block.widgets_values.length > 0 &&
|
||
typeof block.widgets_values[0] === 'string';
|
||
}
|
||
|
||
function promptRoleLabel(role) {
|
||
return t({
|
||
positive: 'recipePromptRolePositive',
|
||
negative: 'recipePromptRoleNegative',
|
||
both: 'recipePromptRoleBoth',
|
||
ignored: 'recipePromptRoleIgnored',
|
||
unknown: 'recipePromptRoleUnknown',
|
||
}[role] || 'recipePromptRoleUnknown');
|
||
}
|
||
|
||
export function applyPromptRolesToBlocks(blocks, promptRoles) {
|
||
if (!Array.isArray(blocks) || !promptRoles || typeof promptRoles !== 'object') return blocks;
|
||
for (const block of blocks) {
|
||
const info = promptRoles[String(block?.node_id)];
|
||
if (!info || typeof info !== 'object') continue;
|
||
block.promptRole = info.role;
|
||
block.promptRoleSource = info.source;
|
||
block.promptRoleManual = info.source === 'manual';
|
||
block.promptRoleAutomatic = info.automatic_role || (info.source === 'manual' ? 'unknown' : info.role);
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
export function mergePromptRoleOverrides(promptRoles, overrides) {
|
||
const roles = { ...(promptRoles || {}) };
|
||
if (!overrides || typeof overrides !== 'object') return roles;
|
||
for (const [nodeId, entry] of Object.entries(overrides)) {
|
||
if (entry?.role) {
|
||
const automatic = roles[String(nodeId)];
|
||
roles[String(nodeId)] = {
|
||
role: entry.role,
|
||
source: 'manual',
|
||
automatic_role: automatic?.automatic_role || automatic?.role || 'unknown',
|
||
};
|
||
}
|
||
}
|
||
return roles;
|
||
}
|
||
|
||
function bindCopyButton(button, value, successKey) {
|
||
const label = button.textContent;
|
||
button.onclick = async event => {
|
||
event.stopPropagation();
|
||
try {
|
||
await navigator.clipboard.writeText(value());
|
||
button.textContent = t(successKey);
|
||
} catch (error) {
|
||
button.textContent = t('materialCopyError');
|
||
}
|
||
setTimeout(() => { if (button.isConnected) button.textContent = label; }, 1200);
|
||
};
|
||
}
|
||
|
||
function renderPromptBlock(content, block, widgetValues) {
|
||
const promptWrap = document.createElement('div');
|
||
promptWrap.className = 'anomalous-material-prompt-station';
|
||
|
||
const promptText = String(widgetValues[0] ?? '');
|
||
const charCount = promptText.length;
|
||
|
||
const bar = document.createElement('div');
|
||
bar.className = 'anomalous-material-prompt-bar';
|
||
|
||
const titleSec = document.createElement('div');
|
||
titleSec.className = 'anomalous-material-prompt-title-sec';
|
||
text(titleSec, 'span', `💬 ${t('materialPromptNode') || '提示词'}`, 'anomalous-material-prompt-title');
|
||
text(titleSec, 'span', t('materialChars', { count: charCount }), 'anomalous-material-prompt-count');
|
||
bar.appendChild(titleSec);
|
||
|
||
const copyPromptBtn = text(bar, 'button', t('materialCopyPromptText'), 'anomalous-material-copy-prompt-btn');
|
||
copyPromptBtn.type = 'button';
|
||
bindCopyButton(copyPromptBtn, () => promptText, 'materialPromptCopied');
|
||
promptWrap.appendChild(bar);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'anomalous-material-prompt-body';
|
||
body.textContent = promptText || t('materialNoWidgetParameters');
|
||
promptWrap.appendChild(body);
|
||
|
||
content.appendChild(promptWrap);
|
||
}
|
||
|
||
function renderNodeParameterRows(content, block, widgetValues, offset = 0) {
|
||
const labels = comfyWidgetLabels(block.type);
|
||
const volatileIndexes = new Set(Array.isArray(block.volatile_widget_indexes) ? block.volatile_widget_indexes : []);
|
||
if (!widgetValues.length) {
|
||
text(content, 'p', t('materialNoWidgetParameters'), 'anomalous-material-muted');
|
||
return;
|
||
}
|
||
widgetValues.forEach((value, localIndex) => {
|
||
const index = localIndex + offset;
|
||
const row = document.createElement('div');
|
||
row.className = 'anomalous-material-parameter-row';
|
||
const labelWrap = document.createElement('div');
|
||
labelWrap.className = 'anomalous-material-parameter-label';
|
||
text(labelWrap, 'span', labels[index] || t('materialWidgetIndex', { index: index + 1 }));
|
||
text(labelWrap, 'code', `#${index}`);
|
||
if (volatileIndexes.has(index)) {
|
||
text(labelWrap, 'span', t('materialVolatileParameter'), 'anomalous-material-volatile-badge');
|
||
}
|
||
const valueText = text(row, 'pre', formatMaterialParameterValue(value), 'anomalous-material-parameter-value');
|
||
valueText.title = t('materialParameterFullValue');
|
||
row.prepend(labelWrap);
|
||
content.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function renderNodeProperties(content, properties) {
|
||
const props = properties && typeof properties === 'object' ? properties : {};
|
||
if (!Object.keys(props).length) return;
|
||
const row = document.createElement('div');
|
||
row.className = 'anomalous-material-parameter-row';
|
||
const labelWrap = document.createElement('div');
|
||
labelWrap.className = 'anomalous-material-parameter-label';
|
||
text(labelWrap, 'span', t('materialNodeProperties'));
|
||
const propertyValue = document.createElement('pre');
|
||
propertyValue.className = 'anomalous-material-parameter-value';
|
||
propertyValue.textContent = formatMaterialParameterValue(props);
|
||
row.append(labelWrap, propertyValue);
|
||
content.appendChild(row);
|
||
}
|
||
|
||
function renderNodeCardContent(node, block, widgetValues) {
|
||
const content = document.createElement('div');
|
||
content.className = 'anomalous-material-node-parameter-content';
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'anomalous-material-node-meta';
|
||
text(meta, 'span', `${t('materialNodeId')}: ${block.node_id ?? '—'}`);
|
||
if (block.mode != null) text(meta, 'span', `${t('materialNodeMode')}: ${block.mode}`);
|
||
|
||
const copy = text(meta, 'button', t('materialCopyNodeParameters'), 'anomalous-material-mini-btn');
|
||
copy.type = 'button';
|
||
bindCopyButton(copy, () => JSON.stringify({
|
||
node_id: block.node_id, type: block.type, title: block.title,
|
||
widgets_values: widgetValues, properties: block.properties || {}, mode: block.mode,
|
||
}, null, 2), 'materialCopied');
|
||
const technical = document.createElement('details');
|
||
technical.className = 'anomalous-notebook-fold';
|
||
text(technical, 'summary', t('recipeAdvancedInfo'));
|
||
technical.appendChild(meta);
|
||
|
||
if (isPromptBlock(block)) {
|
||
renderPromptBlock(content, block, widgetValues);
|
||
if (widgetValues.length > 1) {
|
||
renderNodeParameterRows(content, block, widgetValues.slice(1), 1);
|
||
}
|
||
} else {
|
||
renderNodeParameterRows(content, block, widgetValues);
|
||
}
|
||
|
||
renderNodeProperties(technical, block.properties);
|
||
content.appendChild(technical);
|
||
node.appendChild(content);
|
||
}
|
||
|
||
function renderSingleNodeCard(block, options = {}) {
|
||
const node = document.createElement('details');
|
||
node.className = 'anomalous-material-node-detail';
|
||
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
|
||
|
||
const summary = document.createElement('summary');
|
||
if (options.selectable) {
|
||
const select = document.createElement('input');
|
||
select.type = 'checkbox';
|
||
select.className = 'anomalous-material-node-select';
|
||
select.dataset.nodeId = String(block.node_id);
|
||
select.checked = options.selectedIds?.has(String(block.node_id)) || false;
|
||
select.title = t('materialSelectNodeForSaving');
|
||
select.setAttribute('aria-label', t('materialSelectNodeForSaving'));
|
||
select.addEventListener('click', event => event.stopPropagation());
|
||
select.addEventListener('change', () => {
|
||
options.onSelectionChange?.(block, select.checked);
|
||
node.classList.toggle('is-selected', select.checked);
|
||
});
|
||
node.classList.toggle('is-selected', select.checked);
|
||
summary.appendChild(select);
|
||
}
|
||
|
||
const heading = document.createElement('span');
|
||
heading.className = 'anomalous-material-node-heading';
|
||
text(heading, 'strong', materialNodeHeading(block));
|
||
const renderRoleBadge = () => {
|
||
heading.querySelector('.anomalous-recipe-prompt-role-badge')?.remove();
|
||
if (!block.promptRole) return;
|
||
const badge = text(heading, 'span', promptRoleLabel(block.promptRole), `anomalous-recipe-prompt-role-badge is-${block.promptRole}`);
|
||
badge.title = block.promptRoleManual ? t('recipePromptRoleManual') : t('recipePromptRoleAutomatic');
|
||
};
|
||
renderRoleBadge();
|
||
summary.appendChild(heading);
|
||
|
||
text(summary, 'span', t('materialParameterCount', { count: widgetValues.length }), 'anomalous-material-node-count');
|
||
|
||
if (isPromptBlock(block) && typeof options.onPromptRoleChange === 'function') {
|
||
const roleSelect = document.createElement('select');
|
||
roleSelect.className = 'anomalous-recipe-prompt-role-select anomalous-material-prompt-role-select';
|
||
roleSelect.setAttribute('aria-label', t('recipePromptRoleChoose'));
|
||
const automaticRole = block.promptRoleAutomatic || (block.promptRoleManual ? 'unknown' : block.promptRole) || 'unknown';
|
||
const choices = [
|
||
['auto', `${t('recipePromptRoleAutomatic')} · ${promptRoleLabel(automaticRole)}`],
|
||
['positive', t('recipePromptRolePositive')],
|
||
['negative', t('recipePromptRoleNegative')],
|
||
['both', t('recipePromptRoleBoth')],
|
||
['unknown', t('recipePromptRoleUnknown')],
|
||
['ignored', t('recipePromptRoleIgnored')],
|
||
];
|
||
for (const [value, label] of choices) {
|
||
const option = text(roleSelect, 'option', label);
|
||
option.value = value;
|
||
}
|
||
roleSelect.value = block.promptRoleManual ? block.promptRole : 'auto';
|
||
roleSelect.addEventListener('click', event => event.stopPropagation());
|
||
roleSelect.addEventListener('change', async event => {
|
||
event.stopPropagation();
|
||
const previous = block.promptRoleManual ? block.promptRole : 'auto';
|
||
roleSelect.disabled = true;
|
||
try {
|
||
await options.onPromptRoleChange(block, roleSelect.value, roleSelect);
|
||
renderRoleBadge();
|
||
} catch (error) {
|
||
roleSelect.value = previous;
|
||
console.error('Could not update prompt role:', error);
|
||
} finally {
|
||
if (roleSelect.isConnected) roleSelect.disabled = false;
|
||
}
|
||
});
|
||
roleSelect.hidden = true;
|
||
const adjust = text(summary, 'button', t('promptAdjustRole'), 'anomalous-btn-ghost');
|
||
adjust.type = 'button';
|
||
adjust.setAttribute('aria-expanded', 'false');
|
||
adjust.onclick = event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
roleSelect.hidden = !roleSelect.hidden;
|
||
adjust.setAttribute('aria-expanded', String(!roleSelect.hidden));
|
||
if (!roleSelect.hidden) roleSelect.focus();
|
||
};
|
||
summary.appendChild(roleSelect);
|
||
}
|
||
|
||
if (typeof options.onSaveBlock === 'function') {
|
||
const save = text(summary, 'button', t(isPromptBlock(block) ? 'materialSavePromptAction' : 'materialSaveNode'), 'anomalous-material-node-save');
|
||
save.type = 'button';
|
||
save.title = t('materialSaveNodeHint');
|
||
save.addEventListener('click', event => event.stopPropagation());
|
||
save.addEventListener('click', () => options.onSaveBlock(block, save));
|
||
}
|
||
node.appendChild(summary);
|
||
|
||
const shouldOpen = !options.selectable && isPromptBlock(block);
|
||
let rendered = false;
|
||
|
||
const renderContentIfNeeded = () => {
|
||
if (rendered) return;
|
||
rendered = true;
|
||
renderNodeCardContent(node, block, widgetValues);
|
||
};
|
||
|
||
if (shouldOpen) {
|
||
node.open = true;
|
||
renderContentIfNeeded();
|
||
}
|
||
|
||
node.addEventListener('toggle', () => {
|
||
if (node.open) renderContentIfNeeded();
|
||
});
|
||
|
||
return node;
|
||
}
|
||
|
||
export function renderDetailedNodeCards(parent, blocks, options = {}) {
|
||
const list = document.createElement('div');
|
||
list.className = 'anomalous-material-node-list';
|
||
blocks.forEach(block => {
|
||
list.appendChild(renderSingleNodeCard(block, options));
|
||
});
|
||
parent.appendChild(list);
|
||
return list;
|
||
}
|