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

766 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js';
import { translatePromptText } from './translation_service.js';
export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCardToMixer) {
const sourceCards = [...STARTER_SOURCE_PROMPTS];
let sourceFilterCategory = 'all';
let sourceFilterKeyword = '';
let isCreatingNewCard = false;
let activeCardPreviewPopover = null;
let activeCardAnchorEl = null;
let hidePopoverTimer = null;
let openPopoverTimer = null;
let isPopoverPinned = false;
let lastPointerX = 0;
let lastPointerY = 0;
function isPointInSafeZone(clientX, clientY) {
if (!activeCardPreviewPopover?.isConnected || !activeCardAnchorEl?.isConnected) return false;
const popRect = activeCardPreviewPopover.getBoundingClientRect();
const cardRect = activeCardAnchorEl.getBoundingClientRect();
// 1. Inside anchor card with 4px buffer
if (
clientX >= cardRect.left - 4 &&
clientX <= cardRect.right + 4 &&
clientY >= cardRect.top - 4 &&
clientY <= cardRect.bottom + 4
) {
return true;
}
// 2. Inside popover with 6px buffer
if (
clientX >= popRect.left - 6 &&
clientX <= popRect.right + 6 &&
clientY >= popRect.top - 6 &&
clientY <= popRect.bottom + 6
) {
return true;
}
// 3. Narrow bridging corridor strictly between card and popover
const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true;
let inBridgeX = false;
if (isDockLeft) {
// Card is on the left, Popover is on the right
const bridgeLeft = cardRect.right - 6;
const bridgeRight = popRect.left + 6;
inBridgeX = clientX >= bridgeLeft && clientX <= bridgeRight;
} else {
// Card is on the right, Popover is on the left
const bridgeLeft = popRect.right - 6;
const bridgeRight = cardRect.left + 6;
inBridgeX = clientX >= bridgeLeft && clientX <= bridgeRight;
}
if (inBridgeX) {
const minY = Math.min(cardRect.top, popRect.top) - 10;
const maxY = Math.max(cardRect.bottom, popRect.bottom) + 10;
if (clientY >= minY && clientY <= maxY) {
return true;
}
}
return false;
}
function scheduleHidePopover(fast = false) {
if (isPopoverPinned) return;
clearTimeout(hidePopoverTimer);
const delay = fast ? 90 : 200;
hidePopoverTimer = setTimeout(() => {
if (isPointInSafeZone(lastPointerX, lastPointerY)) {
return;
}
hideCardPreviewPopover();
}, delay);
}
function cancelHidePopover() {
clearTimeout(hidePopoverTimer);
}
function hideCardPreviewPopover(force = false) {
if (isPopoverPinned && !force) return;
clearTimeout(hidePopoverTimer);
clearTimeout(openPopoverTimer);
activeCardAnchorEl?.classList.remove('is-preview-active');
activeCardAnchorEl = null;
activeCardPreviewPopover?.remove();
activeCardPreviewPopover = null;
isPopoverPinned = false;
}
scope.onDispose(() => hideCardPreviewPopover(true));
const onPointerMove = (e) => {
lastPointerX = e.clientX;
lastPointerY = e.clientY;
if (!activeCardPreviewPopover || isPopoverPinned) return;
if (isPointInSafeZone(e.clientX, e.clientY)) {
cancelHidePopover();
} else {
scheduleHidePopover(true);
}
};
scope.listen(window, 'pointermove', onPointerMove, { passive: true });
const onDocPointerDown = (e) => {
if (!activeCardPreviewPopover) return;
if (!activeCardPreviewPopover.contains(e.target) && !activeCardAnchorEl?.contains(e.target)) {
hideCardPreviewPopover(true);
}
};
scope.listen(document, 'pointerdown', onDocPointerDown);
const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel');
const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header');
const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title');
text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library');
const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter');
// Action button group in leftHeader
const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions');
// Button 1: One-click Sync / Import from Material Library
const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
importMaterialsBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library';
// Button 2: Create New Custom Card
const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
newCardTriggerBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card';
// Search and category filters bar
const leftFilterBar = text(leftPanel, 'div', '', 'anomalous-workbench-filter-bar');
const leftSearch = text(leftFilterBar, 'input', '', 'anomalous-workbench-search-input');
leftSearch.placeholder = window.anomalous_browser_lang === 'zh' ? '搜索词卡...' : 'Search cards...';
leftSearch.oninput = () => {
sourceFilterKeyword = leftSearch.value.trim().toLowerCase();
renderSourceCardsList();
};
const leftCategoryPills = text(leftPanel, 'div', '', 'anomalous-workbench-category-pills');
const filterCats = [
{ id: 'all', label: window.anomalous_browser_lang === 'zh' ? '全部' : 'All' },
{ id: 'base', label: window.anomalous_browser_lang === 'zh' ? '底模' : 'Base' },
{ id: 'style', label: window.anomalous_browser_lang === 'zh' ? '风格' : 'Style' },
{ id: 'subject', label: window.anomalous_browser_lang === 'zh' ? '主体' : 'Subject' },
{ id: 'trigger', label: window.anomalous_browser_lang === 'zh' ? '触发' : 'Trigger' },
];
filterCats.forEach(cat => {
const pill = text(leftCategoryPills, 'button', cat.label, `anomalous-workbench-pill${sourceFilterCategory === cat.id ? ' is-active' : ''}`);
pill.onclick = () => {
sourceFilterCategory = cat.id;
leftCategoryPills.querySelectorAll('.anomalous-workbench-pill').forEach(el => el.classList.remove('is-active'));
pill.classList.add('is-active');
renderSourceCardsList();
};
});
// Inline New Card Form (Hidden by default, shown on demand)
const newCardForm = text(leftPanel, 'div', '', 'anomalous-workbench-new-card-form');
newCardForm.style.display = 'none';
// Source Cards List
const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list');
// Dismiss preview popover when pointer is on blank space of the list or panel
sourceCardsList.addEventListener('pointermove', (e) => {
if (isPopoverPinned || !activeCardPreviewPopover) return;
if (e.target === sourceCardsList) {
scheduleHidePopover(true);
}
}, { passive: true });
sourceCardsList.addEventListener('pointerdown', (e) => {
if (e.target === sourceCardsList) {
hideCardPreviewPopover(true);
}
});
leftPanel.addEventListener('pointermove', (e) => {
if (isPopoverPinned || !activeCardPreviewPopover) return;
if (e.target === leftPanel || e.target === leftFilterBar || e.target === leftCategoryPills) {
scheduleHidePopover(true);
}
}, { passive: true });
function detectNodePromptRole(node, widgetName = '') {
if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative';
if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive';
if (app?.graph && Array.isArray(node?.outputs)) {
for (const output of node.outputs) {
if (!Array.isArray(output.links)) continue;
for (const linkId of output.links) {
const link = app.graph.links?.[linkId];
if (!link) continue;
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode?.inputs && targetNode.inputs[link.target_slot]) {
const targetInput = targetNode.inputs[link.target_slot];
const inputName = String(targetInput.name || '').toLowerCase();
if (inputName.includes('neg') || inputName.includes('负')) return 'negative';
if (inputName.includes('pos') || inputName.includes('正')) return 'positive';
}
}
}
}
const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase();
if (/neg|negative|反向|负向/.test(nodeText)) return 'negative';
if (/pos|positive|正面|正向/.test(nodeText)) return 'positive';
return 'positive';
}
// Extract prompts from selected canvas node
function extractPromptsFromSelectedNode(intoRightMixer = false) {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`;
let targets = promptWidgetTargets(node);
// Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths
if (!targets.length && Array.isArray(node.widgets)) {
targets = node.widgets.flatMap((w, idx) => {
const name = String(w.name || '').toLowerCase();
if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) {
return [];
}
if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) {
if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') {
return [{ index: idx, name: w.name || 'text' }];
}
}
return [];
});
}
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!`
: `⚠️ No valid prompt text found in selected node [${heading}]!`);
return;
}
let extractedCount = 0;
targets.forEach(t => {
const rawVal = String(node.widgets[t.index]?.value || '').trim();
if (!rawVal) return;
const role = detectNodePromptRole(node, t.name);
const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal);
const cardTitle = `${heading} · ${t.name}`;
const newCard = {
id: `node_${node.id}_${t.index}_${Date.now()}`,
title: cardTitle,
content: rawVal,
role,
category: cat,
persisted: false,
};
// Add into left source deck
sourceCards.unshift(newCard);
extractedCount++;
// If user clicked right panel, also insert into mixer track directly
if (intoRightMixer) {
addSourceCardToMixer(newCard);
}
});
if (extractedCount > 0) {
renderSourceCardsList();
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!`
: `Successfully extracted ${extractedCount} prompts from [${heading}]!`);
} else {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `选中的节点【${heading}】文本内容为空!`
: `The text fields in node [${heading}] are empty!`);
}
}
let materialSyncController = null;
scope.onDispose(() => materialSyncController?.abort());
async function syncMaterialsIntoSourceDeck(notify = true) {
materialSyncController?.abort();
const controller = new AbortController();
materialSyncController = controller;
const icon = importMaterialsBtn.innerHTML;
importMaterialsBtn.disabled = true;
try {
const cards = await loadPromptSourceCards(controller.signal);
if (scope.signal.aborted || controller.signal.aborted) return;
const addedCount = mergePromptSourceCards(sourceCards, cards);
renderSourceCardsList();
if (notify) showWorkbenchToast(t('promptSourceSynced', { count: addedCount }));
} catch (error) {
if (!scope.signal.aborted && !controller.signal.aborted && notify) await anomalousAlert(t('materialLoadError'));
} finally {
if (materialSyncController === controller) {
materialSyncController = null;
importMaterialsBtn.disabled = false;
importMaterialsBtn.innerHTML = icon;
}
}
}
importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck();
// -------------------------------------------------------------------------
// RENDER: Left Panel Cards & Form
// -------------------------------------------------------------------------
function renderNewCardFormUI() {
if (!isCreatingNewCard) {
newCardForm.style.display = 'none';
newCardForm.replaceChildren();
return;
}
newCardForm.style.display = 'flex';
newCardForm.replaceChildren();
text(newCardForm, 'div', window.anomalous_browser_lang === 'zh' ? '✨ 新建提示词卡片' : '✨ New Prompt Card', 'anomalous-form-title');
const titleInput = text(newCardForm, 'input', '', 'anomalous-form-input');
titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '卡片名称(如:赛博光影、角色面部)...' : 'Card name...';
// Role & Category selector row
const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row');
// Role radio group
const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group');
let selectedRole = activeTab || 'positive';
const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const posRadio = text(posLabel, 'input', '');
posRadio.type = 'radio';
posRadio.name = 'new_card_role';
posRadio.value = 'positive';
posRadio.checked = selectedRole === 'positive';
text(posLabel, 'span', t('promptRolePositive'));
const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const negRadio = text(negLabel, 'input', '');
negRadio.type = 'radio';
negRadio.name = 'new_card_role';
negRadio.value = 'negative';
negRadio.checked = selectedRole === 'negative';
text(negLabel, 'span', t('promptRoleNegative'));
posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; };
negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; };
// Category dropdown
const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap');
text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `);
let selectedCat = 'style';
const catSelect = text(catWrap, 'select', '', 'anomalous-form-select');
['base', 'style', 'subject', 'trigger'].forEach(catKey => {
const meta = CATEGORY_META[catKey];
const opt = text(catSelect, 'option', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en);
opt.value = catKey;
if (catKey === selectedCat) opt.selected = true;
});
catSelect.onchange = () => { selectedCat = catSelect.value; };
const contentInput = text(newCardForm, 'textarea', '', 'anomalous-form-textarea');
contentInput.placeholder = window.anomalous_browser_lang === 'zh' ? '输入提示词内容,多个短语用逗号隔开...' : 'Enter prompt text...';
contentInput.rows = 3;
const formBtnRow = text(newCardForm, 'div', '', 'anomalous-form-btn-row');
const submitBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '✓ 保存并加入库' : '✓ Save to Library', 'anomalous-btn-primary anomalous-btn-sm');
const translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate');
translateBtn.type = 'button';
translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation';
translateBtn.onclick = async () => {
const raw = contentInput.value.trim();
if (!raw) return;
const originalText = translateBtn.textContent;
translateBtn.disabled = true;
translateBtn.textContent = '⏳ ...';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (scope.signal.aborted || !contentInput.isConnected || contentInput.value.trim() !== raw) return;
if (res.ok && res.translated) {
contentInput.value = res.translated;
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
}
} finally {
translateBtn.disabled = false;
translateBtn.textContent = originalText;
}
};
const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm');
submitBtn.onclick = async () => {
const rawContent = contentInput.value.trim();
if (!rawContent) {
contentInput.focus();
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '⏳ ...';
const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en);
const newCard = {
id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
title: cardTitle,
content: rawContent,
role: selectedRole,
category: selectedCat,
persisted: false,
};
try {
const body = {
name: cardTitle,
tags: ['prompt_card', selectedCat, selectedRole],
allow_duplicate: true,
plan: {
version: 2,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
parts: [{
id: newCard.id,
name: cardTitle,
category: selectedCat,
role: selectedRole,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
enabled: true,
}],
},
};
const res = await fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await jsonResponse(res, 'save prompt card');
if (scope.signal.aborted) return;
if (payload.status === 'success') {
newCard.persisted = true;
newCard.filename = payload.filename;
showWorkbenchToast(t('promptSaveCardSuccess'));
}
} catch (err) {
if (scope.signal.aborted) return;
showWorkbenchToast(t('promptSaveCardFailed'));
}
sourceCards.unshift(newCard);
isCreatingNewCard = false;
renderNewCardFormUI();
renderSourceCardsList();
};
cancelBtn.onclick = () => {
isCreatingNewCard = false;
renderNewCardFormUI();
};
titleInput.focus();
}
newCardTriggerBtn.onclick = () => {
isCreatingNewCard = !isCreatingNewCard;
renderNewCardFormUI();
};
function showCardPreviewPopover(card, anchorEl) {
cancelHidePopover();
clearTimeout(openPopoverTimer);
if (!anchorEl?.isConnected) return;
if (isPopoverPinned) return;
if (activeCardAnchorEl && activeCardAnchorEl !== anchorEl) {
activeCardAnchorEl.classList.remove('is-preview-active');
}
activeCardAnchorEl = anchorEl;
anchorEl.classList.add('is-preview-active');
if (activeCardPreviewPopover) {
activeCardPreviewPopover.remove();
activeCardPreviewPopover = null;
}
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const popover = document.createElement('div');
popover.className = 'anomalous-card-preview-popover';
popover.__card = card;
popover.__anchorEl = anchorEl;
popover.onmouseenter = (e) => {
lastPointerX = e.clientX;
lastPointerY = e.clientY;
cancelHidePopover();
clearTimeout(openPopoverTimer);
};
popover.onmouseleave = (e) => {
lastPointerX = e.clientX;
lastPointerY = e.clientY;
scheduleHidePopover();
};
const header = document.createElement('div');
header.className = 'anomalous-popover-header';
const topRow = document.createElement('div');
topRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:6px;';
const tags = document.createElement('div');
tags.className = 'anomalous-popover-tags';
const catBadge = document.createElement('span');
catBadge.className = 'anomalous-popover-cat';
catBadge.style.color = catMeta.color;
catBadge.style.background = catMeta.bg;
catBadge.style.borderColor = catMeta.border;
catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en;
tags.appendChild(catBadge);
const roleBadge = document.createElement('span');
roleBadge.className = `anomalous-popover-role is-${card.role}`;
roleBadge.textContent = card.role === 'negative'
? (window.anomalous_browser_lang === 'zh' ? '⊖ 负向' : '⊖ Negative')
: (window.anomalous_browser_lang === 'zh' ? '⊕ 正向' : '⊕ Positive');
tags.appendChild(roleBadge);
topRow.appendChild(tags);
const topActions = document.createElement('div');
topActions.style.cssText = 'display:flex;align-items:center;gap:4px;';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'anomalous-popover-copy-btn';
copyBtn.innerHTML = '📋 ' + (window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy');
copyBtn.title = window.anomalous_browser_lang === 'zh' ? '复制提示词到剪贴板' : 'Copy prompt text';
copyBtn.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(card.content).then(() => {
copyBtn.innerHTML = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已复制' : 'Copied');
setTimeout(() => {
if (copyBtn.isConnected) copyBtn.innerHTML = '📋 ' + (window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy');
}, 1200);
});
};
topActions.appendChild(copyBtn);
const pinBtn = document.createElement('button');
pinBtn.type = 'button';
pinBtn.className = 'anomalous-popover-pin-btn';
pinBtn.innerHTML = '📌 ' + (window.anomalous_browser_lang === 'zh' ? '固定' : 'Pin');
pinBtn.title = window.anomalous_browser_lang === 'zh' ? '固定浮窗防止移动时自动关闭' : 'Pin preview to keep open';
pinBtn.onclick = (e) => {
e.stopPropagation();
isPopoverPinned = !isPopoverPinned;
popover.classList.toggle('is-pinned', isPopoverPinned);
pinBtn.classList.toggle('is-active', isPopoverPinned);
pinBtn.innerHTML = isPopoverPinned
? '📌 ' + (window.anomalous_browser_lang === 'zh' ? '已固定' : 'Pinned')
: '📌 ' + (window.anomalous_browser_lang === 'zh' ? '固定' : 'Pin');
closeBtn.style.display = isPopoverPinned ? 'inline-flex' : 'none';
};
topActions.appendChild(pinBtn);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'anomalous-popover-close-btn';
closeBtn.innerHTML = '✕';
closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭浮窗' : 'Close popover';
closeBtn.style.display = 'none';
closeBtn.onclick = (e) => {
e.stopPropagation();
hideCardPreviewPopover(true);
};
topActions.appendChild(closeBtn);
topRow.appendChild(topActions);
header.appendChild(topRow);
const titleEl = document.createElement('div');
titleEl.className = 'anomalous-popover-title';
titleEl.textContent = card.title;
header.appendChild(titleEl);
popover.appendChild(header);
const body = document.createElement('div');
body.className = 'anomalous-popover-body';
const snippet = document.createElement('pre');
snippet.className = 'anomalous-popover-snippet';
snippet.textContent = card.content;
body.appendChild(snippet);
popover.appendChild(body);
const footer = document.createElement('div');
footer.className = 'anomalous-popover-footer';
footer.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
const hint = document.createElement('span');
hint.textContent = window.anomalous_browser_lang === 'zh'
? '💡 点击卡片直接添加'
: '💡 Click card to add';
footer.appendChild(hint);
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'anomalous-popover-add-btn';
addBtn.innerHTML = '+ ' + (window.anomalous_browser_lang === 'zh' ? '加入台' : 'Add');
addBtn.title = window.anomalous_browser_lang === 'zh' ? '将词卡加入右侧拼装台' : 'Add card to track';
addBtn.onclick = (e) => {
e.stopPropagation();
addSourceCardToMixer(card);
hideCardPreviewPopover(true);
};
footer.appendChild(addBtn);
popover.appendChild(footer);
document.body.appendChild(popover);
activeCardPreviewPopover = popover;
// Smart Positioning
const rect = anchorEl.getBoundingClientRect();
const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true;
const popoverHeight = popover.offsetHeight || 160;
const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6));
popover.style.top = `${top}px`;
if (isDockLeft) {
popover.classList.add('is-dock-left');
popover.style.left = `${rect.right + 6}px`;
} else {
popover.classList.add('is-dock-right');
popover.style.left = `${Math.max(12, rect.left - 326)}px`;
}
}
function renderSourceCardsList() {
hideCardPreviewPopover(true);
sourceCardsList.replaceChildren();
sourceCardsList.onscroll = () => {
if (!isPopoverPinned) {
hideCardPreviewPopover(true);
}
};
const filtered = sourceCards.filter(card => {
if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false;
if (sourceFilterKeyword) {
const matchTitle = card.title.toLowerCase().includes(sourceFilterKeyword);
const matchContent = card.content.toLowerCase().includes(sourceFilterKeyword);
if (!matchTitle && !matchContent) return false;
}
return true;
});
leftCounter.textContent = `(${filtered.length})`;
if (!filtered.length) {
const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty');
empty.innerHTML = `
<div style="font-size: 24px; margin-bottom: 6px;">🔍</div>
<div>${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}</div>
`;
return;
}
filtered.forEach(card => {
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category} is-role-${card.role}`);
cardEl.setAttribute('draggable', 'true');
// Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip)
cardEl.onmouseenter = (e) => {
lastPointerX = e.clientX;
lastPointerY = e.clientY;
cancelHidePopover();
clearTimeout(openPopoverTimer);
if (activeCardPreviewPopover && activeCardPreviewPopover.__card === card) {
return;
}
if (isPopoverPinned) return;
openPopoverTimer = setTimeout(() => {
showCardPreviewPopover(card, cardEl);
}, 100);
};
cardEl.onmouseleave = (e) => {
lastPointerX = e.clientX;
lastPointerY = e.clientY;
clearTimeout(openPopoverTimer);
scheduleHidePopover();
};
// Drag Start
cardEl.ondragstart = (e) => {
hideCardPreviewPopover(true);
const payload = {
title: card.title,
content: card.content,
role: card.role,
category: card.category,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', card.content);
e.dataTransfer.effectAllowed = 'copyMove';
cardEl.classList.add('is-dragging-source');
workbenchGrid.classList.add('is-source-dragging');
};
cardEl.ondragend = () => {
cardEl.classList.remove('is-dragging-source');
workbenchGrid.classList.remove('is-source-dragging');
};
const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot');
dot.style.backgroundColor = catMeta.color;
const roleTag = text(cardEl, 'span', card.role === 'negative' ? (window.anomalous_browser_lang === 'zh' ? '⊖ 负' : '⊖ Neg') : (window.anomalous_browser_lang === 'zh' ? '⊕ 正' : '⊕ Pos'), `anomalous-card-role-tag is-${card.role}`);
roleTag.title = card.role === 'negative' ? (window.anomalous_browser_lang === 'zh' ? '负向词卡' : 'Negative card') : (window.anomalous_browser_lang === 'zh' ? '正向词卡' : 'Positive card');
const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name');
const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon');
cardEl.onclick = () => {
hideCardPreviewPopover(true);
addSourceCardToMixer(card);
};
});
}
renderNewCardFormUI();
renderSourceCardsList();
void syncMaterialsIntoSourceDeck(false);
return {
extractSelected: extractPromptsFromSelectedNode,
refresh: renderSourceCardsList,
sync: syncMaterialsIntoSourceDeck,
hidePreview: (force = false) => hideCardPreviewPopover(force),
};
}