Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_material_cards.js
T

288 lines
13 KiB
JavaScript

/** Material Library list-card rendering and inline title editing. */
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 { applyLibraryMaterial } from './ui_material_application.js';
import {
deleteMaterial,
getMaterialPlaceholderSvg,
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);
}
export function renderMaterialCard(owner, material) {
const card = document.createElement('article');
card.className = 'anomalous-material-card';
card.title = t('materialViewDetails') || '点击查看详细参数';
const activate = () => showMaterialDetail(owner, material);
card.onclick = activate;
if (material.node_types?.length) {
bindMaterialDrag(card, owner, {
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
accepts: (node, source) => source.node_types.includes(node.type),
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
});
}
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 = '<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
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 = '<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
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 = `
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z"></path>
</svg>
`;
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 = `<span>⚡</span> <span>${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}</span>`;
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 = `<span>⏳</span> <span>${t('assistantApplying') || '应用中...'}</span>`;
try {
await applyLibraryMaterial(owner, material);
applyBtn.innerHTML = `<span>✅</span> <span>${t('assistantApplied') || '已应用'}</span>`;
setTimeout(() => {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.innerHTML = `<span>⚡</span> <span>${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}</span>`;
}
}, 2000);
} catch (err) {
console.error(err);
applyBtn.disabled = false;
applyBtn.innerHTML = `<span>⚡</span> <span>${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}</span>`;
}
};
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;
}