feat(ui): redesign prompt composer with paper-like note cards, active drag, canvas reveal, and material import drawer

This commit is contained in:
DemonGatanjieu
2026-09-09 21:53:28 +08:00
parent 2c51befe66
commit 39a7d7094a
6 changed files with 896 additions and 107 deletions
+6 -2
View File
@@ -94,10 +94,14 @@ DOM or live LiteGraph state.
and global collapse/expand toggles; delegates image inspection to `ui_gallery_detail.js`;
`node_material_actions.js` owns shared transactional node application and guarded undo;
the library and Node Assistant use `ui_material_application.js` for the same receipt.
- `ui_prompt_composer.js` owns Prompt Combinations inside the library;
- `ui_prompt_composer.js` owns the Prompt Combinations paper note studio, featuring
paper-like note cards with colored washi tapes, active whole-card dragging, quick
actions, and a slide-out drawer to conveniently import prompts from the Material Library;
`prompt_composition.js` joins text at the beginning/end and reads older fragment plans.
- `material_drag.js` owns temporary canvas drop listeners, target hit testing and
window restoration. Dropped materials use replacement; prompt-panel drops insert text.
window restoration. During active dragging, the main modal smoothly transitions to
full transparency (`opacity: 0; pointer-events: none`) to fully reveal the underlying
ComfyUI canvas, restoring on drop/cancel. Dropped materials use replacement; prompt-panel drops insert text.
- `material_inspector.js` owns shared image-metadata parsing and node-parameter
rendering used by the library and Image Detail Workbench. Node cards feature leading
aligned frosted-glass checkboxes with cyan micro-interactions for batch selection.
+28 -2
View File
@@ -58,8 +58,21 @@ export const i18n = {
promptImportPlan: "导入提示词方案",
promptAlreadyImported: "素材库中已有相同的提示词方案。",
promptImportError: "导入失败,请选择有效的提示词方案 JSON 文件(不超过 2 MiB)。",
promptExportLocation: "提示词方案的导出按钮位于方案编辑页。",
promptNewDraft: "新建搭配",
promptImportFromMaterials: "从素材库导入",
promptImportOverwrite: "覆盖",
promptImportAppend: "追加",
promptImportOverwritePrompt: "覆盖当前",
promptImportAppendPrompt: "追加到当前",
promptPositiveNote: "正向提示词便签",
promptNegativeNote: "负向提示词便签",
promptWordCount: "{count} 字符",
promptDragNoteHint: "按住卡头或便签,直接拖拽至画布节点",
promptDrawerTitle: "素材库提示词速选",
promptDrawerSearchPlaceholder: "搜索素材名称或提示词...",
promptDrawerEmpty: "未在素材库中找到可用的提示词素材",
promptClearText: "清空便签",
promptClearConfirm: "确定要清空该便签的内容吗?",
promptEnablePart: "启用这个片段",
promptUnnamedPart: "未命名片段",
promptPartCategory: "片段用途",
@@ -1067,8 +1080,21 @@ export const i18n = {
promptImportPlan: "Import prompt plan",
promptAlreadyImported: "An identical prompt plan is already in the library.",
promptImportError: "Import failed. Choose a valid prompt plan JSON file (up to 2 MiB).",
promptExportLocation: "Open a prompt plan to export it from its editor.",
promptNewDraft: "New combination",
promptImportFromMaterials: "Import from Materials",
promptImportOverwrite: "Overwrite",
promptImportAppend: "Append",
promptImportOverwritePrompt: "Overwrite current",
promptImportAppendPrompt: "Append to current",
promptPositiveNote: "Positive Prompt Note",
promptNegativeNote: "Negative Prompt Note",
promptWordCount: "{count} chars",
promptDragNoteHint: "Hold card or header to drag onto canvas node",
promptDrawerTitle: "Material Library Prompts",
promptDrawerSearchPlaceholder: "Search material title or prompt text...",
promptDrawerEmpty: "No prompt materials found in library",
promptClearText: "Clear note",
promptClearConfirm: "Are you sure you want to clear this note?",
promptEnablePart: "Enable this fragment",
promptUnnamedPart: "Untitled fragment",
promptPartCategory: "Fragment category",
+4 -2
View File
@@ -33,7 +33,8 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop }) {
event.stopPropagation();
event.dataTransfer.setData('application/x-anomalous-material', 'local-drag');
event.dataTransfer.effectAllowed = 'copy';
const hint = text(document.body, 'div', t('materialDropHint'), 'anomalous-material-drag-hint');
const defaultHint = data.dragHint || t('materialDropHint');
const hint = text(document.body, 'div', defaultHint, 'anomalous-material-drag-hint');
hint.setAttribute('role', 'status');
const cleanup = () => {
clearTimeout(reveal);
@@ -48,7 +49,8 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop }) {
const node = target(event);
const valid = !!node && accepts(node, data);
event.dataTransfer.dropEffect = valid ? 'copy' : 'none';
hint.textContent = valid ? t('materialDropTarget', { name: materialNodeHeading(node) }) : t('materialDropHint');
hint.classList.toggle('is-target-valid', valid);
hint.textContent = valid ? t('materialDropTarget', { name: materialNodeHeading(node) }) : defaultHint;
hint.style.left = `${Math.max(8, Math.min(event.clientX + 16, window.innerWidth - 250))}px`;
hint.style.top = `${Math.max(8, event.clientY - 48)}px`;
};
+1 -2
View File
@@ -451,11 +451,10 @@ function renderMaterialCard(owner, material) {
card.onclick = activate;
if (material.node_types?.length) {
bindMaterialDrag(card, owner, {
payload: () => ({ ...material, node_types: [...material.node_types] }),
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),
});
text(card, 'span', t('materialDragParameters'), 'anomalous-material-drag-label');
}
card.tabIndex = 0;
card.setAttribute('aria-label', `${material.name} — ${t('materialViewDetails')}`);
+399 -86
View File
@@ -15,7 +15,11 @@ export function addPromptToDraft(owner, name, positive, negative = '') {
owner.promptPlanDraft ||= newDraft();
const current = composePromptPlan(owner.promptPlanDraft.plan);
owner.promptPlanDraft.name ||= String(name).slice(0, 120);
owner.promptPlanDraft.plan = { parts: [], positive: joinPromptText(current.positive, positive, 'after'), negative: joinPromptText(current.negative, negative, 'after') };
owner.promptPlanDraft.plan = {
parts: [],
positive: joinPromptText(current.positive, positive, 'after'),
negative: joinPromptText(current.negative, negative, 'after'),
};
showPromptComposer(owner);
}
@@ -35,8 +39,11 @@ export async function showPromptComposer(owner, material) {
} catch (error) {
if (error.name !== 'AbortError') await anomalousAlert(t('materialDetailLoadError'));
return;
} finally { if (owner.materialDetailController === controller) owner.materialDetailController = null; }
} finally {
if (owner.materialDetailController === controller) owner.materialDetailController = null;
}
}
owner.promptPlanDraft ||= newDraft();
clearTimeout(owner.materialSearchTimer);
owner.materialListController?.abort();
@@ -44,136 +51,442 @@ export async function showPromptComposer(owner, material) {
owner.materialDetailController?.abort();
owner.materialDetailView?.remove();
owner.materialDetailView = null;
for (const panel of [owner.materialIntro, owner.materialList, owner.materialToolbar, owner.materialPager, owner.materialContext]) if (panel) panel.style.display = 'none';
for (const panel of [owner.materialIntro, owner.materialList, owner.materialToolbar, owner.materialPager, owner.materialContext]) {
if (panel) panel.style.display = 'none';
}
owner.promptComposerView?.remove();
const view = text(owner.materialView, 'section', '', 'anomalous-prompt-composer');
owner.promptComposerView = view;
const draft = owner.promptPlanDraft;
draft.plan = { parts: [], ...composePromptPlan(draft.plan) };
const header = text(view, 'div', '', 'anomalous-prompt-actions');
const back = text(header, 'button', t('materialBackToLibrary'), 'anomalous-btn-ghost');
back.onclick = () => owner.showMaterials();
text(header, 'h3', t('promptCombinations'));
const name = text(view, 'input', '', 'anomalous-nb-select');
name.placeholder = t('promptPlanName'); name.setAttribute('aria-label', t('promptPlanName')); name.maxLength = 120; name.value = draft.name;
name.oninput = () => { draft.name = name.value; };
const tags = text(view, 'input', '', 'anomalous-nb-select');
tags.placeholder = t('materialTagsHint'); tags.setAttribute('aria-label', t('materialTags')); tags.value = draft.tags.join(', '); tags.maxLength = 1200;
tags.oninput = () => { draft.tags = tags.value.split(/[,,]/).map(value => value.trim()).filter(Boolean); };
text(view, 'p', t('promptCompositionHint'), 'anomalous-material-muted');
text(view, 'small', t('promptDraftHint'), 'anomalous-material-muted');
const field = (parent, label, value, oninput, readOnly = false) => {
const wrap = text(parent, 'label', label, 'anomalous-prompt-field');
const input = text(wrap, 'textarea', ''); input.value = value; input.rows = 3; input.readOnly = readOnly;
input.setAttribute('aria-label', label); input.oninput = () => oninput(input.value);
return input;
// 1. Topbar
const topbar = text(view, 'div', '', 'anomalous-prompt-topbar');
const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left');
const backBtn = text(topLeft, 'button', `← ${t('materialBackToLibrary')}`, 'anomalous-btn-ghost');
backBtn.onclick = () => owner.showMaterials();
text(topLeft, 'h3', t('promptCombinations'));
const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions');
const importBtn = text(topActions, 'button', `📥 ${t('promptImportFromMaterials')}`, 'anomalous-btn-ghost');
importBtn.title = t('promptDrawerTitle');
const saveBtn = text(topActions, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-primary');
const exportBtn = text(topActions, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost');
const newBtn = text(topActions, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost');
// 2. Metadata strip (Name, Tags, Position)
const metaStrip = text(view, 'div', '', 'anomalous-prompt-meta-strip');
const nameInput = text(metaStrip, 'input', '', 'anomalous-prompt-name-input');
nameInput.placeholder = t('promptPlanName');
nameInput.maxLength = 120;
nameInput.value = draft.name;
nameInput.oninput = () => { draft.name = nameInput.value; };
const tagsInput = text(metaStrip, 'input', '', 'anomalous-prompt-tags-input');
tagsInput.placeholder = t('materialTagsHint');
tagsInput.value = draft.tags.join(', ');
tagsInput.maxLength = 1200;
tagsInput.oninput = () => {
draft.tags = tagsInput.value.split(/[,,]/).map(val => val.trim()).filter(Boolean);
};
const placement = text(view, 'label', t('promptInsertPosition'), 'anomalous-prompt-field');
const position = text(placement, 'select', '');
position.setAttribute('aria-label', t('promptInsertPosition'));
for (const value of ['before', 'after']) text(position, 'option', t(`promptInsert_${value}`)).value = value;
position.value = owner.promptInsertPosition || 'after';
position.onchange = () => { owner.promptInsertPosition = position.value; };
for (const role of ['positive', 'negative']) {
const card = text(view, 'div', '', 'anomalous-prompt-drag-card');
field(card, t(`promptFinal_${role}`), draft.plan[role], value => { draft.plan[role] = value; });
const drag = text(card, 'button', t('promptDragText'), 'anomalous-btn-primary');
drag.type = 'button';
bindMaterialDrag(drag, owner, {
payload: () => draft.plan[role].trim() ? { content: draft.plan[role], position: position.value } : null,
accepts: node => promptWidgetTargets(node).length > 0,
drop: (node, data, graph) => applyPromptDrop(node, data, graph, targetArea),
});
const copy = text(card, 'button', t(`promptCopy_${role}`), 'anomalous-btn-ghost');
copy.onclick = async () => {
try { await navigator.clipboard.writeText(draft.plan[role]); copy.textContent = t('materialCopied'); }
catch (error) { await anomalousAlert(t('materialCopyError')); }
};
const posWrap = text(metaStrip, 'label', '', 'anomalous-prompt-insert-pos');
text(posWrap, 'span', `${t('promptInsertPosition')}:`);
const posSelect = text(posWrap, 'select', '');
for (const value of ['before', 'after']) {
text(posSelect, 'option', t(`promptInsert_${value}`)).value = value;
}
const targetArea = text(view, 'div', '', 'anomalous-prompt-target');
const renderTarget = () => {
posSelect.value = owner.promptInsertPosition || 'after';
posSelect.onchange = () => { owner.promptInsertPosition = posSelect.value; };
// 3. Paper-like Note Cards Grid
const notesGrid = text(view, 'div', '', 'anomalous-paper-notes-grid');
const renderNoteCard = (role, isPositive) => {
const card = text(notesGrid, 'article', '', `anomalous-paper-note ${isPositive ? 'is-positive' : 'is-negative'}`);
card.setAttribute('draggable', 'true');
card.setAttribute('aria-label', t(isPositive ? 'promptPositiveNote' : 'promptNegativeNote'));
// Tape effect on top
text(card, 'div', '', 'anomalous-paper-note-tape');
// Header
const noteHeader = text(card, 'div', '', 'anomalous-paper-note-header');
const noteTitle = text(noteHeader, 'div', '', 'anomalous-paper-note-title');
noteTitle.innerHTML = isPositive ? `✨ ${t('promptPositiveNote')}` : `🚫 ${t('promptNegativeNote')}`;
const noteActions = text(noteHeader, 'div', '', 'anomalous-paper-note-actions');
const charCount = text(noteActions, 'span', t('promptWordCount', { count: draft.plan[role].length }), 'anomalous-paper-note-count');
const copyBtn = text(noteActions, 'button', `📋 ${t('copy')}`, 'anomalous-paper-note-btn');
copyBtn.title = t(`promptCopy_${role}`);
copyBtn.onclick = async event => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(draft.plan[role]);
copyBtn.textContent = '✅ 已复制';
setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = `📋 ${t('copy')}`; }, 1500);
} catch (err) {
await anomalousAlert(t('materialCopyError'));
}
};
const clearBtn = text(noteActions, 'button', `🧹`, 'anomalous-paper-note-btn');
clearBtn.title = t('promptClearText');
clearBtn.onclick = async event => {
event.stopPropagation();
if (draft.plan[role].trim() && await anomalousConfirm(t('promptClearConfirm'))) {
draft.plan[role] = '';
textarea.value = '';
charCount.textContent = t('promptWordCount', { count: 0 });
}
};
// Textarea body
const noteBody = text(card, 'div', '', 'anomalous-paper-note-body');
const textarea = text(noteBody, 'textarea', '', 'anomalous-paper-note-textarea');
textarea.value = draft.plan[role] || '';
textarea.placeholder = isPositive ? '输入或导入正面提示词...' : '输入或导入负面提示词...';
textarea.rows = 5;
textarea.oninput = () => {
draft.plan[role] = textarea.value;
charCount.textContent = t('promptWordCount', { count: textarea.value.length });
};
// Footer with drag hint
const noteFooter = text(card, 'div', '', 'anomalous-paper-note-footer');
const dragPrompt = text(noteFooter, 'span', '', 'anomalous-paper-note-drag-prompt');
dragPrompt.innerHTML = `🖐️ ${t('promptDragNoteHint')}`;
// Bind Whole Card Drag
bindMaterialDrag(card, owner, {
payload: () => draft.plan[role].trim() ? {
content: draft.plan[role],
position: posSelect.value,
dragHint: isPositive ? `✨ 拖拽正面提示词便签至目标节点` : `🚫 拖拽负面提示词便签至目标节点`,
} : null,
accepts: node => promptWidgetTargets(node).length > 0,
drop: (node, data, graph) => applyPromptDrop(node, data, graph, targetBar),
});
return {
update: () => {
textarea.value = draft.plan[role] || '';
charCount.textContent = t('promptWordCount', { count: textarea.value.length });
},
};
};
const posCardControl = renderNoteCard('positive', true);
const negCardControl = renderNoteCard('negative', false);
const refreshAllNotes = () => {
posCardControl.update();
negCardControl.update();
nameInput.value = draft.name || '';
tagsInput.value = (draft.tags || []).join(', ');
};
// 4. Target Node Bar & Direct Application
const targetBar = text(view, 'div', '', 'anomalous-prompt-target-bar');
const renderTargetBar = () => {
if (!view.isConnected) return;
targetArea.replaceChildren();
targetBar.replaceChildren();
const node = selectedMaterialNode(app);
const targets = promptWidgetTargets(node);
if (!node || !targets.length) { text(targetArea, 'p', t('promptSelectTextNode')); return; }
text(targetArea, 'strong', t('materialApplyingTo', { name: materialNodeHeading(node), id: node.id }));
const widget = text(targetArea, 'select', ''); widget.setAttribute('aria-label', t('promptTargetWidget'));
for (const target of targets) text(widget, 'option', target.name).value = String(target.index);
const role = text(targetArea, 'select', ''); role.setAttribute('aria-label', t('promptTargetRole'));
for (const value of ['positive', 'negative']) text(role, 'option', t(`promptFinal_${value}`)).value = value;
for (const placement of ['before', 'after']) {
const action = text(targetArea, 'button', t(`promptInsert_${placement}`), 'anomalous-btn-primary');
action.onclick = async () => {
if (!node || !targets.length) {
text(targetBar, 'span', `💡 ${t('promptSelectTextNode')}`, 'anomalous-prompt-target-info');
return;
}
const info = text(targetBar, 'div', '', 'anomalous-prompt-target-info');
info.innerHTML = `🎯 ${t('materialApplyingTo', { name: materialNodeHeading(node), id: node.id })}`;
const actions = text(targetBar, 'div', '', 'anomalous-prompt-target-actions');
const widgetSelect = text(actions, 'select', '');
widgetSelect.setAttribute('aria-label', t('promptTargetWidget'));
for (const target of targets) {
text(widgetSelect, 'option', target.name).value = String(target.index);
}
const applyRole = (role, label) => {
const btn = text(actions, 'button', label, 'anomalous-btn-primary');
btn.type = 'button';
btn.onclick = async () => {
try {
if (selectedMaterialNode(app) !== node) throw new Error('materialTargetChanged');
const index = Number(widget.value);
if (!promptWidgetTargets(node).some(target => target.index === index)) throw new Error('materialTargetChanged');
const content = composePromptPlan(draft.plan)[role.value];
if (!content.trim()) return;
const value = joinPromptText(node.widgets[index].value, content, placement);
showMaterialApplication(targetArea, applyNodeMaterialValues(app, node, [{ index, value }]), node);
} catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
const index = Number(widgetSelect.value);
if (!promptWidgetTargets(node).some(t => t.index === index)) throw new Error('materialTargetChanged');
const content = draft.plan[role];
if (!content || !content.trim()) return;
const value = joinPromptText(node.widgets[index].value, content, posSelect.value);
showMaterialApplication(targetBar, applyNodeMaterialValues(app, node, [{ index, value }]), node);
} catch (error) {
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
}
};
}
};
applyRole('positive', `写入正面 (${t(`promptInsert_${posSelect.value}`)})`);
applyRole('negative', `写入负面 (${t(`promptInsert_${posSelect.value}`)})`);
};
owner.refreshPromptTarget = renderTarget;
const actions = text(view, 'div', '', 'anomalous-prompt-actions');
const save = text(actions, 'button', t('promptSavePlan'), 'anomalous-btn-primary');
save.onclick = async () => {
if (!draft.name.trim()) { name.focus(); return; }
save.disabled = true;
owner.refreshPromptTarget = renderTargetBar;
renderTargetBar();
// 5. Drawer Integration: Import Prompts from Material Library
importBtn.onclick = () => {
openMaterialImportDrawer(owner, draft, refreshAllNotes);
};
// 6. Action Handlers (Save, Export, Reset)
saveBtn.onclick = async () => {
if (!draft.name.trim()) {
nameInput.focus();
return;
}
saveBtn.disabled = true;
const body = clone(draft);
const send = () => fetch('/anomalous/save_prompt_plan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const send = () => fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
try {
let response = await send();
if (response.status === 409) {
const duplicate = await response.json();
if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name }))) return;
body.allow_duplicate = true; response = await send();
body.allow_duplicate = true;
response = await send();
}
const payload = await jsonResponse(response, 'prompt save failed');
if (payload.status !== 'success') throw new Error('prompt save failed');
showMaterialSaved(owner, payload.material);
} catch (error) { await anomalousAlert(t('materialSaveError')); }
finally { save.disabled = false; }
} catch (error) {
await anomalousAlert(t('materialSaveError'));
} finally {
saveBtn.disabled = false;
}
};
const exportPlan = text(actions, 'button', t('promptExportPlan'), 'anomalous-btn-ghost');
exportPlan.onclick = () => {
if (!draft.name.trim()) { name.focus(); return; }
exportBtn.onclick = () => {
if (!draft.name.trim()) {
nameInput.focus();
return;
}
const blob = new Blob([JSON.stringify({ format: 'anomalous-prompt-plan-v1', ...draft }, null, 2)], { type: 'application/json' });
if (blob.size > 2 * 1024 * 1024 || draft.tags.length > 20 || draft.tags.some(tag => tag.length > 60)) {
return anomalousAlert(t('promptExportError'));
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'prompt-plan.json'; a.click();
const a = document.createElement('a');
a.href = url;
a.download = `${draft.name || 'prompt-plan'}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const reset = text(actions, 'button', t('promptNewDraft'), 'anomalous-btn-ghost');
reset.onclick = async () => { if (await anomalousConfirm(t('promptReplaceDraft'))) { owner.promptPlanDraft = newDraft(); showPromptComposer(owner); } };
renderTarget();
}
newBtn.onclick = async () => {
if (await anomalousConfirm(t('promptReplaceDraft'))) {
owner.promptPlanDraft = newDraft();
showPromptComposer(owner);
}
};
}
function applyPromptDrop(node, data, graph, parent) {
const targets = promptWidgetTargets(node);
const apply = index => {
if (app.graph !== graph || graph.getNodeById(node.id) !== node || !promptWidgetTargets(node).some(target => target.index === index)) throw new Error('materialTargetChanged');
if (app.graph !== graph || graph.getNodeById(node.id) !== node || !promptWidgetTargets(node).some(target => target.index === index)) {
throw new Error('materialTargetChanged');
}
const value = joinPromptText(node.widgets[index].value, data.content, data.position);
showMaterialApplication(parent, applyNodeMaterialValues(app, node, [{ index, value }]), node);
};
if (targets.length === 1) { apply(targets[0].index); return; }
if (targets.length === 1) {
apply(targets[0].index);
return;
}
if (!targets.length) throw new Error('materialNoCompatibleValues');
const dialog = text(document.body, 'dialog', '', 'anomalous-material-choice');
text(dialog, 'h3', t('promptChooseTarget'));
const status = text(dialog, 'p', ''); status.setAttribute('role', 'alert');
const status = text(dialog, 'p', '');
status.setAttribute('role', 'alert');
for (const target of targets) {
const choose = text(dialog, 'button', target.name, 'anomalous-btn-primary');
choose.onclick = () => {
try { apply(target.index); dialog.close(); }
catch (error) { status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message); }
try {
apply(target.index);
dialog.close();
} catch (error) {
status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message);
}
};
}
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost'); close.onclick = () => dialog.close();
dialog.onclose = () => dialog.remove(); dialog.showModal();
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost');
close.onclick = () => dialog.close();
dialog.onclose = () => dialog.remove();
dialog.showModal();
}
/**
* Slide-out Drawer: Quick Prompt Importer from Material Library
*/
async function openMaterialImportDrawer(owner, draft, onUpdated) {
document.querySelector('.anomalous-prompt-import-drawer')?.remove();
const drawer = document.createElement('div');
drawer.className = 'anomalous-prompt-import-drawer';
// Header
const header = text(drawer, 'div', '', 'anomalous-drawer-header');
text(header, 'h4', `📥 ${t('promptDrawerTitle')}`);
const closeBtn = text(header, 'button', '✕', 'anomalous-paper-note-btn');
closeBtn.onclick = () => drawer.remove();
// Search bar
const searchWrap = text(drawer, 'div', '', 'anomalous-drawer-search');
const searchInput = text(searchWrap, 'input', '');
searchInput.placeholder = t('promptDrawerSearchPlaceholder');
searchInput.type = 'search';
// List container
const listContainer = text(drawer, 'div', '', 'anomalous-drawer-list');
text(listContainer, 'div', '⏳ 正在加载素材库提示词...', 'anomalous-material-muted');
document.body.appendChild(drawer);
const onKeydown = e => {
if (e.key === 'Escape') {
drawer.remove();
window.removeEventListener('keydown', onKeydown);
}
};
window.addEventListener('keydown', onKeydown);
try {
const response = await fetch('/anomalous/materials?limit=100');
const payload = await jsonResponse(response, 'load materials failed');
const items = payload.data?.items || [];
// Extract and shape prompt data
const promptMaterials = [];
for (const item of items) {
let pos = '';
let neg = '';
if (item.kind === 'prompt_plan' && item.plan) {
pos = item.plan.positive || '';
neg = item.plan.negative || '';
} else if (item.kind === 'prompt_text') {
pos = item.content || item.summary || '';
} else if (Array.isArray(item.node_blocks)) {
for (const block of item.node_blocks) {
const textVal = Array.isArray(block.widgets_values) && typeof block.widgets_values[0] === 'string'
? block.widgets_values[0]
: '';
if (!textVal) continue;
if (block.promptRole === 'positive') pos = pos ? `${pos}, ${textVal}` : textVal;
else if (block.promptRole === 'negative') neg = neg ? `${neg}, ${textVal}` : textVal;
else if (/cliptextencode/i.test(block.type || '')) pos = pos ? `${pos}, ${textVal}` : textVal;
}
}
if (pos || neg || item.kind === 'prompt_plan') {
promptMaterials.push({
raw: item,
name: item.name || '未命名素材',
positive: pos,
negative: neg,
tags: item.tags || [],
});
}
}
const renderList = (filter = '') => {
listContainer.replaceChildren();
const lower = filter.toLowerCase().trim();
const filtered = promptMaterials.filter(m =>
!lower ||
m.name.toLowerCase().includes(lower) ||
m.positive.toLowerCase().includes(lower) ||
m.negative.toLowerCase().includes(lower) ||
m.tags.some(tag => tag.toLowerCase().includes(lower))
);
if (!filtered.length) {
text(listContainer, 'div', t('promptDrawerEmpty'), 'anomalous-material-empty');
return;
}
for (const item of filtered) {
const card = text(listContainer, 'div', '', 'anomalous-drawer-item');
text(card, 'div', item.name, 'anomalous-drawer-item-title');
if (item.positive) {
const prev = text(card, 'div', `✨ 正面: ${item.positive}`, 'anomalous-drawer-item-preview');
prev.title = item.positive;
}
if (item.negative) {
const prev = text(card, 'div', `🚫 负面: ${item.negative}`, 'anomalous-drawer-item-preview');
prev.title = item.negative;
}
const actions = text(card, 'div', '', 'anomalous-drawer-item-actions');
if (item.positive) {
const overwritePos = text(actions, 'button', `覆盖正面`, 'anomalous-drawer-item-btn');
overwritePos.onclick = () => {
draft.plan.positive = item.positive;
onUpdated();
};
const appendPos = text(actions, 'button', `追加正面`, 'anomalous-drawer-item-btn');
appendPos.onclick = () => {
draft.plan.positive = joinPromptText(draft.plan.positive, item.positive, owner.promptInsertPosition || 'after');
onUpdated();
};
}
if (item.negative) {
const overwriteNeg = text(actions, 'button', `覆盖负面`, 'anomalous-drawer-item-btn');
overwriteNeg.onclick = () => {
draft.plan.negative = item.negative;
onUpdated();
};
const appendNeg = text(actions, 'button', `追加负面`, 'anomalous-drawer-item-btn');
appendNeg.onclick = () => {
draft.plan.negative = joinPromptText(draft.plan.negative, item.negative, owner.promptInsertPosition || 'after');
onUpdated();
};
}
const loadAll = text(actions, 'button', `整套导入`, 'anomalous-drawer-item-btn');
loadAll.onclick = () => {
if (item.positive) draft.plan.positive = item.positive;
if (item.negative) draft.plan.negative = item.negative;
if (!draft.name.trim() && item.name) draft.name = item.name;
if (item.tags?.length) draft.tags = [...new Set([...draft.tags, ...item.tags])];
onUpdated();
};
}
};
renderList();
searchInput.oninput = () => renderList(searchInput.value);
searchInput.focus();
} catch (err) {
listContainer.replaceChildren();
text(listContainer, 'div', `加载素材库失败: ${err.message}`, 'anomalous-material-empty');
}
}
+458 -13
View File
@@ -7366,17 +7366,462 @@ dialog.anomalous-gallery-viewer::backdrop {
background: #202428;
color: #eee;
}
.anomalous-material-drag-label {
position: absolute;
top: 10px;
left: 10px;
padding: 4px 8px;
background: #202428;
color: #eee;
border-radius: 6px;
font-size: 12px;
pointer-events: none;
/* ==========================================================================
Prompt Composer & Paper Note Studio / Material Drag System
========================================================================== */
/* 1. Modal Dragging State: Instantly ghost modal to reveal ComfyUI canvas */
#anomalous-modal.anomalous-material-dragging {
pointer-events: none !important;
}
.anomalous-material-card[draggable="true"], .anomalous-prompt-drag-card button[draggable="true"] { cursor: grab; }
.anomalous-prompt-drag-card { padding: 12px; border: 1px solid var(--border-color, #454545); border-radius: 8px; }
.anomalous-prompt-drag-card > button { margin: 10px 8px 0 0; }
#anomalous-modal.anomalous-material-dragging #anomalous-container {
opacity: 0 !important;
pointer-events: none !important;
transform: scale(0.985);
transition: opacity 0.16s cubic-bezier(0.4, 0, 0.2, 1), transform 0.16s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 2. Drag Cursor & Hover Feedback */
.anomalous-material-card[draggable="true"],
.anomalous-paper-note[draggable="true"] {
cursor: grab;
}
.anomalous-material-card[draggable="true"]:active,
.anomalous-paper-note[draggable="true"]:active {
cursor: grabbing;
}
/* Hide legacy bulky drag label to restore clean card visuals */
.anomalous-material-drag-label {
display: none !important;
}
/* 3. Drag Follower Floating Badge */
.anomalous-material-drag-hint {
position: fixed;
z-index: 999999;
pointer-events: none;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-radius: 20px;
background: rgba(15, 23, 42, 0.9);
border: 1.5px solid rgba(45, 212, 191, 0.55);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.65), 0 0 16px rgba(45, 212, 191, 0.2);
color: #f1f5f9;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.2;
backdrop-filter: blur(8px);
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.anomalous-material-drag-hint.is-target-valid {
border-color: #2dd4bf;
background: rgba(13, 37, 42, 0.94);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.75), 0 0 24px rgba(45, 212, 191, 0.45);
color: #5eead4;
}
/* 4. Prompt Composer Layout (Warm Paper Studio) */
.anomalous-prompt-composer {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 20px 24px;
overflow-y: auto;
max-height: 100%;
color: #e2e8f0;
}
.anomalous-prompt-topbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.anomalous-prompt-topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.anomalous-prompt-topbar-left h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: #f8fafc;
letter-spacing: 0.02em;
}
.anomalous-prompt-topbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.anomalous-prompt-meta-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
flex-wrap: wrap;
}
.anomalous-prompt-meta-strip input {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 6px 10px;
color: #f1f5f9;
font-size: 0.82rem;
outline: none;
transition: border-color 0.15s ease;
}
.anomalous-prompt-meta-strip input:focus {
border-color: #2dd4bf;
}
.anomalous-prompt-name-input {
flex: 1 1 200px;
}
.anomalous-prompt-tags-input {
flex: 1 1 180px;
}
.anomalous-prompt-insert-pos {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: #94a3b8;
}
.anomalous-prompt-insert-pos select {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 5px 8px;
color: #f1f5f9;
font-size: 0.78rem;
outline: none;
cursor: pointer;
}
/* 5. Paper-like Note Cards Grid */
.anomalous-paper-notes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 18px;
}
.anomalous-paper-note {
position: relative;
display: flex;
flex-direction: column;
border-radius: 12px;
background: linear-gradient(175deg, #1c2028 0%, #15181f 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 10px -2px rgba(0, 0, 0, 0.25);
overflow: hidden;
transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.18s ease;
user-select: none;
}
.anomalous-paper-note:hover {
transform: translateY(-2px);
box-shadow: 0 14px 30px -4px rgba(0, 0, 0, 0.55), 0 6px 14px -2px rgba(0, 0, 0, 0.35);
border-color: rgba(255, 255, 255, 0.18);
}
/* Note Top Tape Effect (纸胶带) */
.anomalous-paper-note-tape {
height: 6px;
width: 100%;
opacity: 0.85;
}
.anomalous-paper-note.is-positive .anomalous-paper-note-tape {
background: linear-gradient(90deg, #2dd4bf 0%, #0d9488 100%);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.35);
}
.anomalous-paper-note.is-negative .anomalous-paper-note-tape {
background: linear-gradient(90deg, #fb7185 0%, #e11d48 100%);
box-shadow: 0 0 10px rgba(251, 113, 133, 0.35);
}
/* Note Header */
.anomalous-paper-note-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 6px;
gap: 8px;
}
.anomalous-paper-note-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.86rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.anomalous-paper-note.is-positive .anomalous-paper-note-title {
color: #5eead4;
}
.anomalous-paper-note.is-negative .anomalous-paper-note-title {
color: #fda4af;
}
.anomalous-paper-note-actions {
display: flex;
align-items: center;
gap: 6px;
}
.anomalous-paper-note-count {
font-size: 0.72rem;
color: #64748b;
font-family: monospace;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
}
.anomalous-paper-note-btn {
padding: 3px 8px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #cbd5e1;
font-size: 0.74rem;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.anomalous-paper-note-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #f8fafc;
border-color: rgba(255, 255, 255, 0.25);
}
/* Note Textarea */
.anomalous-paper-note-body {
padding: 6px 14px 10px;
flex: 1;
display: flex;
flex-direction: column;
}
.anomalous-paper-note-textarea {
width: 100%;
min-height: 120px;
resize: vertical;
border: none;
outline: none;
background: rgba(0, 0, 0, 0.2);
color: #f1f5f9;
font-size: 0.85rem;
line-height: 1.5;
padding: 10px;
border-radius: 6px;
font-family: inherit;
box-sizing: border-box;
transition: background 0.15s ease;
}
.anomalous-paper-note-textarea:focus {
background: rgba(0, 0, 0, 0.35);
}
/* Note Footer Drag Hint */
.anomalous-paper-note-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px 10px;
font-size: 0.73rem;
color: #64748b;
border-top: 1px dashed rgba(255, 255, 255, 0.06);
}
.anomalous-paper-note-drag-prompt {
display: inline-flex;
align-items: center;
gap: 5px;
color: #94a3b8;
}
/* 6. Active Target Status & Injection Bar */
.anomalous-prompt-target-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(45, 212, 191, 0.2);
}
.anomalous-prompt-target-info {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
color: #cbd5e1;
}
.anomalous-prompt-target-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 7. Slide-out Material Import Drawer */
.anomalous-prompt-import-drawer {
position: fixed;
top: 0;
right: 0;
width: 440px;
max-width: 90vw;
height: 100vh;
background: rgba(18, 22, 30, 0.96);
border-left: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.75);
z-index: 10005;
display: flex;
flex-direction: column;
backdrop-filter: blur(12px);
animation: drawerSlideIn 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes drawerSlideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.anomalous-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.anomalous-drawer-header h4 {
margin: 0;
font-size: 0.98rem;
font-weight: 600;
color: #f1f5f9;
}
.anomalous-drawer-search {
padding: 10px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.anomalous-drawer-search input {
width: 100%;
box-sizing: border-box;
padding: 7px 12px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #f1f5f9;
font-size: 0.82rem;
outline: none;
}
.anomalous-drawer-search input:focus {
border-color: #2dd4bf;
}
.anomalous-drawer-list {
flex: 1;
overflow-y: auto;
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.anomalous-drawer-item {
padding: 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 8px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.anomalous-drawer-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(45, 212, 191, 0.3);
}
.anomalous-drawer-item-title {
font-size: 0.84rem;
font-weight: 600;
color: #f8fafc;
}
.anomalous-drawer-item-preview {
font-size: 0.76rem;
color: #94a3b8;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
padding: 6px 8px;
border-radius: 4px;
}
.anomalous-drawer-item-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.anomalous-drawer-item-btn {
padding: 4px 9px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #cbd5e1;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s ease;
}
.anomalous-drawer-item-btn:hover {
background: #2dd4bf;
color: #0f172a;
border-color: #2dd4bf;
font-weight: 600;
}