From 39a7d7094a93ea5e870dd4ded1fef99137cab391 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Wed, 9 Sep 2026 21:53:28 +0800 Subject: [PATCH] feat(ui): redesign prompt composer with paper-like note cards, active drag, canvas reveal, and material import drawer --- ARCHITECTURE.md | 8 +- web/modules/locales.js | 30 +- web/modules/material_drag.js | 6 +- web/modules/ui_materials.js | 3 +- web/modules/ui_prompt_composer.js | 485 ++++++++++++++++++++++++------ web/styles.css | 471 ++++++++++++++++++++++++++++- 6 files changed, 896 insertions(+), 107 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fee51f4..0c482aa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/web/modules/locales.js b/web/modules/locales.js index c47177b..6173780 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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", diff --git a/web/modules/material_drag.js b/web/modules/material_drag.js index 4a5186b..f8244c4 100644 --- a/web/modules/material_drag.js +++ b/web/modules/material_drag.js @@ -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`; }; diff --git a/web/modules/ui_materials.js b/web/modules/ui_materials.js index 3e47960..dacf016 100644 --- a/web/modules/ui_materials.js +++ b/web/modules/ui_materials.js @@ -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')}`); diff --git a/web/modules/ui_prompt_composer.js b/web/modules/ui_prompt_composer.js index fd9f53d..b5c79ea 100644 --- a/web/modules/ui_prompt_composer.js +++ b/web/modules/ui_prompt_composer.js @@ -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'); + } } diff --git a/web/styles.css b/web/styles.css index 6274b05..aa1fe7f 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; +} +