/** Material Library list-card rendering and inline title editing. */
import { app } from '../../../scripts/app.js';
import { bindMaterialDrag } from './material_drag.js';
import { translate } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { text, jsonResponse } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { promptWidgetTargets } from './node_material_actions.js';
import { applyLibraryMaterial } from './ui_material_application.js';
import {
deleteMaterial,
getMaterialPlaceholderSvg,
getMaterialPromptInfo,
isPromptMaterial,
materialAssetUrl,
openMaterialWorkflow,
promptKindLabel,
renderSourceRecipeMark,
showMaterialDetail,
} from './ui_material_detail.js';
const t = (key, params) => translate(key, params);
function startInlineTitleEdit(owner, material, titleRow, cardTitle, editBtn) {
if (titleRow.querySelector('.anomalous-material-inline-input')) return;
const originalName = material.name || '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'anomalous-material-inline-input';
input.value = originalName;
input.maxLength = 120;
input.placeholder = t('materialName') || '输入短标题…';
cardTitle.style.display = 'none';
editBtn.style.display = 'none';
titleRow.appendChild(input);
input.focus();
input.select();
let isSaving = false;
const finish = async (shouldSave) => {
if (isSaving) return;
const newName = input.value.trim();
if (shouldSave && newName && newName !== originalName) {
isSaving = true;
input.disabled = true;
try {
const response = await fetch('/anomalous/update_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: material.filename,
name: newName,
tags: material.tags || [],
}),
});
const payload = await jsonResponse(response, 'material update failed');
if (payload.status === 'success' && payload.material) {
Object.assign(material, payload.material);
cardTitle.textContent = material.name;
cardTitle.title = material.name;
}
} catch (err) {
console.warn('Failed to update title:', err);
}
}
input.remove();
cardTitle.style.display = '';
editBtn.style.display = '';
};
input.onclick = (e) => e.stopPropagation();
input.onkeydown = (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
finish(true);
} else if (e.key === 'Escape') {
e.preventDefault();
finish(false);
}
};
input.onblur = () => finish(true);
}
function bindPolymorphicMaterialCardDrag(card, owner, material) {
const isZh = window.anomalous_browser_lang === 'zh';
const isWorkflow = (material.capabilities || []).includes('open_workflow') || material.has_workflow;
const isPrompt = isPromptMaterial(material) || material.kind === 'prompt_plan';
let cardTitleText = `${material.name || t('materialUntitled')}`;
let dragHint = '';
let dragTargetHint = '';
if (isWorkflow) {
cardTitleText += ` — ${isZh ? '按住拖至空白画布载入完整工作流' : 'Drag to blank canvas to load workflow'}`;
dragHint = isZh ? '拖拽至空白画布载入工作流' : 'Drag to blank canvas to load workflow';
dragTargetHint = isZh ? '松开以载入完整工作流' : 'Release to load workflow';
} else if (isPrompt) {
cardTitleText += ` — ${isZh ? '按住拖至文本节点注入提示词,或拖至空白处创建提示词节点' : 'Drag to text node to inject prompt, or to blank canvas to create node'}`;
dragHint = isZh ? '拖拽提示词至文本节点或空白画布' : 'Drag prompt to text node or blank canvas';
dragTargetHint = isZh ? '松开以在空白画布创建提示词节点' : 'Release to create prompt node on blank canvas';
} else {
cardTitleText += ` — ${isZh ? '按住拖至节点注入参数,或拖至空白处新建对应节点' : 'Drag to node to apply parameters, or to blank canvas to create node'}`;
dragHint = t('materialDragParameters') || (isZh ? '拖拽素材参数至目标节点' : 'Drag parameters to target node');
dragTargetHint = isZh ? '松开以在空白画布创建对应节点' : 'Release to create node on blank canvas';
}
card.title = cardTitleText;
bindMaterialDrag(card, owner, {
payload: () => ({
...material,
node_types: Array.isArray(material.node_types) ? [...material.node_types] : [],
dragHint,
dragTargetHint,
}),
accepts: (node, source) => {
if (!node) return false;
const promptMat = isPromptMaterial(source) || source.kind === 'prompt_plan';
if (promptMat) {
return promptWidgetTargets(node).length > 0 || (source.node_types || []).includes(node.type);
}
return (source.node_types || []).includes(node.type);
},
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
dropOnCanvas: async (event, source, graph, position) => {
if (isWorkflow) {
await openMaterialWorkflow(owner, source.filename);
return;
}
const creator = (typeof LiteGraph !== 'undefined' ? LiteGraph?.createNode : null)
|| globalThis.LiteGraph?.createNode
|| window.LiteGraph?.createNode;
if (!creator) return;
const pos = position || (app.canvas?.convertEventToCanvasOffset
? app.canvas.convertEventToCanvasOffset(event)
: [100, 100]);
if (isPrompt) {
const info = getMaterialPromptInfo(source);
const isNegative = info.role === 'negative';
const node = creator.call(LiteGraph, 'CLIPTextEncode');
if (!node) return;
node.title = isNegative
? (isZh ? 'CLIP 文本编码器 (负向)' : 'CLIP Text Encode (Negative)')
: (isZh ? 'CLIP 文本编码器 (正向)' : 'CLIP Text Encode (Positive)');
node.color = isNegative ? '#532323' : '#235327';
node.bgcolor = isNegative ? '#381616' : '#143818';
node.pos = [pos[0], pos[1]];
const tw = (node.widgets || []).find(w => /^(text|prompt)/i.test(w.name) || w.type === 'customtext') || node.widgets?.[0];
if (tw) {
tw.value = info.text;
if (Array.isArray(node.widgets_values)) node.widgets_values[0] = info.text;
tw.callback?.call(tw, tw.value, app.canvas, node);
node.onWidgetChanged?.(0, tw.value, '', tw);
}
graph.add(node);
app.canvas?.selectNode?.(node);
app.canvas?.setDirty?.(true, true);
graph.change?.();
return;
}
if (source.node_types?.length) {
const nodeType = source.node_types[0];
const node = creator.call(LiteGraph, nodeType);
if (node) {
node.pos = [pos[0], pos[1]];
graph.add(node);
app.canvas?.selectNode?.(node);
app.canvas?.setDirty?.(true, true);
graph.change?.();
try {
await applyLibraryMaterial(owner, source, node, graph);
} catch (err) {
console.warn('[AMB] Error applying parameters to newly created node:', err);
}
}
}
},
});
}
export function renderMaterialCard(owner, material) {
const card = document.createElement('article');
card.className = 'anomalous-material-card';
const activate = () => showMaterialDetail(owner, material);
card.onclick = activate;
bindPolymorphicMaterialCardDrag(card, owner, material);
card.tabIndex = 0;
card.setAttribute('aria-label', `${material.name} — ${t('materialViewDetails')}`);
card.onkeydown = event => {
if (event.target === card && ['Enter', ' '].includes(event.key)) {
event.preventDefault();
activate();
}
};
const actionsWrapper = document.createElement('div');
actionsWrapper.className = 'anomalous-material-card-actions-wrapper';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'anomalous-material-card-action-btn anomalous-material-card-delete';
remove.innerHTML = '';
remove.title = t('materialDelete');
remove.onclick = (e) => {
e.stopPropagation();
deleteMaterial(owner, material);
};
card.appendChild(remove);
const removeClone = typeof remove.cloneNode === 'function' ? remove.cloneNode(true) : document.createElement('button');
actionsWrapper.appendChild(removeClone);
removeClone.onclick = remove.onclick;
if (!owner.materialApplyMode && (material.capabilities || []).includes('open_workflow')) {
const quickOpen = document.createElement('button');
quickOpen.type = 'button';
quickOpen.className = 'anomalous-material-card-action-btn anomalous-material-card-quick-load';
quickOpen.innerHTML = '';
quickOpen.title = t('materialOpenWorkflow');
quickOpen.onclick = async (e) => {
e.stopPropagation();
try {
await openMaterialWorkflow(owner, material.filename);
} catch (error) {
await anomalousAlert(t('materialOpenError'));
}
};
card.appendChild(quickOpen);
const quickOpenClone = typeof quickOpen.cloneNode === 'function' ? quickOpen.cloneNode(true) : document.createElement('button');
actionsWrapper.appendChild(quickOpenClone);
quickOpenClone.onclick = quickOpen.onclick;
}
const preview = document.createElement('div');
preview.className = 'anomalous-material-card-preview';
const previewUrl = materialAssetUrl(material.filename, material.image?.preview_asset_id || material.image?.source_asset_id);
if (previewUrl) {
const image = document.createElement('img');
image.src = previewUrl;
image.alt = material.name || t('materialUntitled');
image.loading = 'lazy';
image.draggable = false;
preview.appendChild(image);
} else {
if (isPromptMaterial(material) || material.kind === 'prompt_plan') {
preview.classList.add('is-prompt-fallback');
preview.innerHTML = getMaterialPlaceholderSvg(material, 28);
const snippet = document.createElement('div');
snippet.className = 'anomalous-material-preview-snippet';
snippet.textContent = material.summary || material.name || 'Prompt Note';
preview.appendChild(snippet);
} else if (material.kind === 'recipe_parameter_selection') {
preview.classList.add('is-params-fallback');
preview.innerHTML = getMaterialPlaceholderSvg(material, 28);
const snippet = document.createElement('div');
snippet.className = 'anomalous-material-preview-snippet';
snippet.textContent = material.name || 'Parameters Scheme';
preview.appendChild(snippet);
} else {
preview.classList.add('is-workflow-fallback');
preview.innerHTML = getMaterialPlaceholderSvg(material, 28);
}
}
card.appendChild(preview);
const body = document.createElement('div');
body.className = 'anomalous-material-card-body';
const titleRow = document.createElement('div');
titleRow.className = 'anomalous-material-card-title-row';
const cardTitle = document.createElement('h3');
cardTitle.className = 'anomalous-material-card-title-text';
cardTitle.textContent = material.name || t('materialUntitled');
cardTitle.title = material.name || t('materialUntitled');
titleRow.appendChild(cardTitle);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'anomalous-material-card-edit-btn';
editBtn.title = t('materialQuickEditTitle') || '修改短标题';
editBtn.innerHTML = `
`;
editBtn.onclick = (e) => {
e.stopPropagation();
startInlineTitleEdit(owner, material, titleRow, cardTitle, editBtn);
};
titleRow.appendChild(editBtn);
body.appendChild(titleRow);
const metadata = document.createElement('div');
metadata.className = 'anomalous-material-card-meta';
if (material.kind === 'prompt_plan') {
text(metadata, 'span', t('promptPlan'), 'anomalous-material-scope-badge');
} else if (isPromptMaterial(material)) {
text(metadata, 'span', promptKindLabel(material), 'anomalous-material-scope-badge is-prompt');
} else if (material.kind === 'recipe_parameter_selection') {
text(metadata, 'span', t('materialRecipeParameterMaterial'), 'anomalous-material-scope-badge is-nodes');
} else if (material.selection?.scope === 'nodes') {
const isPromptOnly = Array.isArray(material.node_types) &&
material.node_types.length > 0 &&
material.node_types.every(tp => /cliptextencode/i.test(tp));
const badgeText = isPromptOnly
? `💬 ${t('materialPromptNode') || '提示词'}`
: t('materialSelectedNodeMaterial');
text(metadata, 'span', badgeText, 'anomalous-material-scope-badge is-nodes');
} else {
text(metadata, 'span', t('materialFullWorkflowMaterial'), 'anomalous-material-scope-badge is-workflow');
}
if (!isPromptMaterial(material) && material.kind !== 'prompt_plan') text(metadata, 'span', t('materialNodeSummary', { count: material.node_count || 0 }), 'anomalous-material-meta-pill');
body.appendChild(metadata);
const secondaryRow = document.createElement('div');
secondaryRow.className = 'anomalous-material-card-subinfo';
if (material.timestamp) {
const dateStr = new Date(material.timestamp).toLocaleDateString();
text(secondaryRow, 'span', dateStr, 'anomalous-material-card-date');
}
renderSourceRecipeMark(secondaryRow, material.source_recipe);
if (secondaryRow.hasChildNodes()) {
body.appendChild(secondaryRow);
}
if (Array.isArray(material.node_types) && material.node_types.length) {
const displayLimit = 2;
const shownTypes = material.node_types.slice(0, displayLimit);
const labels = shownTypes.map(type => materialNodeHeading({ type }));
const remaining = material.node_types.length - shownTypes.length;
let summaryText = labels.join(' · ');
if (remaining > 0) {
summaryText += t('materialOtherNodeTypes', { count: remaining });
}
const types = text(body, 'small', summaryText, 'anomalous-material-types');
types.title = material.node_types.map(type => materialNodeHeading({ type })).join(' · ');
}
if (material.tags?.length) {
const tags = text(body, 'div', '', 'anomalous-material-tags');
material.tags.forEach(tag => text(tags, 'span', tag, 'anomalous-material-tag'));
}
if (owner.materialApplyMode) {
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'anomalous-material-card-apply-btn';
applyBtn.innerHTML = `⚡ ${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}`;
applyBtn.title = t('materialApplyCard') || (window.anomalous_browser_lang === 'zh' ? '点击将本方案参数应用替换到当前节点' : 'Click to apply parameters to current node');
applyBtn.onclick = async (e) => {
e.stopPropagation();
applyBtn.disabled = true;
applyBtn.innerHTML = `⏳ ${t('assistantApplying') || '应用中...'}`;
try {
await applyLibraryMaterial(owner, material);
applyBtn.innerHTML = `✅ ${t('assistantApplied') || '已应用'}`;
setTimeout(() => {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.innerHTML = `⚡ ${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}`;
}
}, 2000);
} catch (err) {
console.error(err);
applyBtn.disabled = false;
applyBtn.innerHTML = `⚡ ${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}`;
}
};
body.appendChild(applyBtn);
const applyBtnClone = applyBtn.cloneNode(true);
applyBtnClone.onclick = applyBtn.onclick;
actionsWrapper.insertBefore(applyBtnClone, actionsWrapper.firstChild);
}
card.appendChild(body);
card.appendChild(actionsWrapper);
return card;
}