Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_material_cards.js
T
DemonGatanjieuandClaude Opus 5.5 51b7113f5c feat(drag): preview a drop before release
While a prompt is over a prompt box, the text a release writes is laid
over that box and the opposite-role box on the same sampler; leaving the
box, the canvas or the drag removes it. The overlay never touches the
widget's value.

Over a node of a type the material holds values for, the hint lists what
would change ("steps 12 -> 28"), as applyMaterialBlock does it: seeds stay,
and a choice this computer lacks is named. Every dragged material is now
fetched when its drag starts (prepareMaterialDrag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:05:43 +08:00

420 lines
20 KiB
JavaScript

/** 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 {
carriesPrompt, clearDropPreview, markTargetBox, outlinePromptBoxes, parameterDropHint, prepareMaterialDrag,
previewPromptDrop, promptBoxAt, promptDropHint, promptRefusal,
} from './prompt_drop.js';
import { applyLibraryMaterial, fetchMaterial } from './ui_material_application.js';
import { extractMaterialPromptEnvelope } from './node_material_actions.js';
import { recordCanvasStep } from './canvas_history.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import {
deleteMaterial,
getMaterialPlaceholderSvg,
isPromptMaterial,
materialAssetUrl,
openMaterialWorkflow,
promptKindLabel,
renderSourceRecipeMark,
showMaterialDetail,
} from './ui_material_detail.js';
const t = (key, params) => translate(key, params);
/** Undo for the prompt nodes a drop created: removed while still there and unconnected. */
function removeMade(graph, nodes) {
const loose = node => graph.getNodeById(node.id) === node
&& !(node.inputs || []).some(input => input?.link != null) && !(node.outputs || []).some(output => output?.links?.length);
if (app.graph !== graph || !nodes.every(loose)) { showWorkbenchToast(t('materialUndoChanged')); return; }
graph.beforeChange?.();
try { for (const node of nodes) graph.remove(node); } finally { graph.afterChange?.(); }
graph.setDirtyCanvas?.(true, true);
recordCanvasStep(app);
}
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 += ` — ${t('promptDragTitle')}`;
dragHint = t('promptDragHint');
dragTargetHint = t('promptDragCanvas');
} 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;
// A prompt goes into the box under the pointer (prompt_drop.js); other values need the same node type.
const prompt = carriesPrompt(material, isPrompt);
bindMaterialDrag(card, owner, {
payload: () => ({
...material,
node_types: Array.isArray(material.node_types) ? [...material.node_types] : [],
dragHint,
dragTargetHint,
}),
accepts: (node, source, event) => (prompt && !promptRefusal(node, event)) || (source.node_types || []).includes(node.type),
targetHint: (node, source, event) => {
const box = prompt ? promptBoxAt(node, event) : null;
return box ? promptDropHint(source, node, box) : parameterDropHint(source, node);
},
rejectHint: (node, source, event) => (prompt ? promptRefusal(node, event) : ''),
onStart: (source) => {
prepareMaterialDrag(source);
if (!prompt) return null;
const removeOutlines = outlinePromptBoxes(app.graph);
return () => { clearDropPreview(); removeOutlines(); };
},
onMove: (node, source, event) => {
if (!prompt) return;
const box = node && promptBoxAt(node, event);
markTargetBox(box);
previewPromptDrop(source, node, box);
},
drop: (node, source, graph, event) => applyLibraryMaterial(owner, source, node, graph, { box: prompt ? promptBoxAt(node, event) : null }),
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) {
// One prompt node per side the material has; the list summary may not carry the text.
const payload = await fetchMaterial(source.filename);
if (app.graph !== graph) throw new Error('materialTargetChanged');
const envelope = extractMaterialPromptEnvelope(source, payload);
const sides = [['positive', envelope.positive], ['negative', envelope.negative]].filter(([, value]) => value);
if (!sides.length && envelope.singleText) sides.push([envelope.primaryRole === 'negative' ? 'negative' : 'positive', envelope.singleText]);
if (!sides.length) throw new Error('materialNoCompatibleValues');
const made = [];
sides.forEach(([role, value], index) => {
const isNegative = 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] + index * 260];
graph.add(node);
made.push(node);
const box = node.widgets?.find(widget => widget.name === 'text');
if (box) {
box.value = value;
box.callback?.call(box, value, app.canvas, node);
node.onWidgetChanged?.(box.name, value, '', box);
}
});
if (made.length) app.canvas?.selectNode?.(made[0]);
app.canvas?.setDirty?.(true, true);
graph.change?.();
recordCanvasStep(app);
if (made.length) showWorkbenchToast(t('promptNodesCreated', { count: made.length }), { label: t('activityUndo'), run: () => removeMade(graph, made) });
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?.();
recordCanvasStep(app);
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 = '<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) {
// One apply button per card, in both grid and list layouts. The locale text already carries its icon.
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'anomalous-material-card-apply-btn';
applyBtn.textContent = t('assistantApplyScheme');
applyBtn.title = t('materialApplyCard');
applyBtn.onclick = async (e) => {
e.stopPropagation();
applyBtn.disabled = true;
applyBtn.textContent = t('assistantApplying');
try {
await applyLibraryMaterial(owner, material);
applyBtn.textContent = t('assistantApplied');
setTimeout(() => {
applyBtn.disabled = false;
applyBtn.textContent = t('assistantApplyScheme');
}, 2000);
} catch (err) {
console.error(err);
applyBtn.disabled = false;
applyBtn.textContent = t('assistantApplyScheme');
}
};
body.appendChild(applyBtn);
}
card.appendChild(body);
card.appendChild(actionsWrapper);
return card;
}