/** * ui_prompt_translator.js * Standalone Prompt Translator modal for Anomalous Model Browser. * Provides instant multi-language translation, tag breakdown chips, * active ComfyUI node read/write, and direct dispatch into Prompt Studio. */ import { app } from '../../../scripts/app.js'; import { translatePromptText, splitPromptTags, normalizePromptFormatting, hasChinese } from './translation_service.js'; import { selectedMaterialNode, promptWidgetTargets, applyNodeMaterialValues } from './node_material_actions.js'; import { createViewScope, bindDrawerResize } from './ui_lifecycle.js'; import { appendPromptToStudio } from './ui_prompt_composer.js'; let activeTranslator = null; function t(zh, en) { return window.anomalous_browser_lang === 'zh' ? zh : en; } function showTranslatorToast(container, message, isError = false) { const existing = container.querySelector('.anomalous-translator-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.className = `anomalous-translator-toast${isError ? ' is-error' : ''}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.classList.add('is-fade-out'); setTimeout(() => toast.remove(), 400); }, 2000); } function getCanvasSelectedNodes() { try { const canvas = app?.canvas || window?.app?.canvas; const sel = canvas?.selected_nodes; if (Array.isArray(sel)) return sel.filter(Boolean); if (sel instanceof Set || sel instanceof Map) return Array.from(sel.values()).filter(Boolean); if (sel && typeof sel === 'object') return Object.values(sel).filter(Boolean); } catch { // ignore } return []; } /** * Extracts prompt text and widget target from any supported canvas node. * Supports standard CLIPTextEncode, Chinese-localized widgets, multiline text, and custom nodes. * @param {Object} node * @returns {{ text: string, widgetIndex: number, widgetName: string, nodeTitle: string } | null} */ export function extractPromptFromNode(node) { if (!node || !Array.isArray(node.widgets)) return null; // 1. Try standard promptWidgetTargets try { const targets = promptWidgetTargets(node); if (targets.length) { const widget = node.widgets[targets[0].index]; const val = typeof widget?.value === 'string' ? widget.value.trim() : ''; if (val) { return { text: val, widgetIndex: targets[0].index, widgetName: targets[0].name || 'text', nodeTitle: node.title || node.type || 'Node', }; } } } catch { // ignore } // 2. Check English and Chinese text widget names & labels const promptNameRegex = /^(text|text_g|text_l|prompt|positive|negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)/i; for (let i = 0; i < node.widgets.length; i++) { const w = node.widgets[i]; if (!w) continue; const name = String(w.name || ''); const label = String(w.label || ''); const notCombo = !w.options?.values || !Array.isArray(w.options.values); const isStringVal = typeof w.value === 'string'; if (isStringVal && notCombo && (promptNameRegex.test(name) || promptNameRegex.test(label) || w.type === 'customtext' || w.type === 'text' || !!w.options?.multiline)) { const val = w.value.trim(); if (val) { return { text: val, widgetIndex: i, widgetName: w.name || w.label || 'text', nodeTitle: node.title || node.type || 'Node', }; } } } // 3. Fallback: Any string widget that is not a dropdown combo and has non-empty text for (let i = 0; i < node.widgets.length; i++) { const w = node.widgets[i]; if (!w) continue; const notCombo = !w.options?.values || !Array.isArray(w.options.values); if (typeof w.value === 'string' && notCombo && w.value.trim().length > 0) { return { text: w.value.trim(), widgetIndex: i, widgetName: w.name || w.label || 'text', nodeTitle: node.title || node.type || 'Node', }; } } // 4. Fallback: node.widgets_values if (Array.isArray(node.widgets_values)) { for (let i = 0; i < node.widgets_values.length; i++) { const val = node.widgets_values[i]; if (typeof val === 'string' && val.trim().length > 0) { const w = node.widgets[i]; const notCombo = !w?.options?.values || !Array.isArray(w.options.values); if (notCombo) { return { text: val.trim(), widgetIndex: i, widgetName: w?.name || w?.label || 'text', nodeTitle: node.title || node.type || 'Node', }; } } } } return null; } export function getCurrentlySelectedNode() { try { const canvas = app?.canvas || window?.app?.canvas; const selectedNodes = getCanvasSelectedNodes(); if (selectedNodes.length) { for (const n of selectedNodes) { if (n && extractPromptFromNode(n)) return n; } return selectedNodes[0]; } if (canvas?.current_node) { return canvas.current_node; } const materialNode = selectedMaterialNode(app); if (materialNode) return materialNode; } catch { // ignore } return null; } /** * Reads prompt text from the currently selected canvas node. * @param {Object} [targetNode=null] * @returns {{ text: string, nodeTitle: string, widgetIndex?: number, widgetName?: string } | null} */ export function readSelectedNodePrompt(targetNode = null) { try { const node = targetNode || getCurrentlySelectedNode(); if (!node) return null; return extractPromptFromNode(node); } catch { return null; } } /** * Writes translated text into the currently selected canvas node. * @param {string} text * @returns {{ success: boolean, message: string }} */ export function writeToSelectedNode(text) { try { const node = getCurrentlySelectedNode(); if (!node) { return { success: false, message: t('未选中任何画布节点,请先在 ComfyUI 画布上点击选中目标节点', 'No node selected on canvas. Please click a node first.'), }; } const promptInfo = extractPromptFromNode(node); let targetIndex = promptInfo ? promptInfo.widgetIndex : -1; let targetName = promptInfo ? promptInfo.widgetName : 'text'; if (targetIndex < 0) { const textWidgetIndex = (node.widgets || []).findIndex(w => typeof w?.value === 'string' && (!w.options?.values || Array.isArray(w.options.values) === false) ); if (textWidgetIndex >= 0) { targetIndex = textWidgetIndex; targetName = node.widgets[textWidgetIndex].name || node.widgets[textWidgetIndex].label || 'text'; } } if (targetIndex < 0) { return { success: false, message: t(`节点【${node.title || node.type}】没有可写入的提示词文本输入框`, `Node [${node.title || node.type}] has no text widget.`), }; } applyNodeMaterialValues(app, node, [{ index: targetIndex, value: text }]); return { success: true, message: t(`✓ 已成功写入节点【${node.title || node.type}】的 ${targetName} 框`, `✓ Written to [${node.title || node.type}] (${targetName})`), }; } catch (err) { return { success: false, message: t(`写入失败: ${err.message}`, `Write failed: ${err.message}`), }; } } /** * Opens the standalone Prompt Translator modal. * @param {Object} owner - AnomalousBrowser instance */ export function openPromptTranslator(owner) { activeTranslator?.dispose(); const scope = createViewScope(); activeTranslator = scope; // Modal Overlay const overlay = document.createElement('div'); overlay.className = 'anomalous-translator-overlay'; scope.onDispose(() => { overlay.remove(); if (activeTranslator === scope) activeTranslator = null; }); // Modal Window const modal = document.createElement('div'); modal.className = 'anomalous-translator-modal'; overlay.appendChild(modal); // Canvas Focus Mode: If left master browser is open, auto-collapse it so canvas has 80%+ full space let masterWasVisible = false; if (owner && owner.modal && owner.modal.classList.contains('visible')) { masterWasVisible = true; if (typeof owner.close === 'function') { owner.close(); } } // Left/Right edge resize handle for sidebar mode const resizeHandle = document.createElement('div'); resizeHandle.className = 'anomalous-translator-resize-handle'; resizeHandle.title = t('拖动调整侧边栏宽度,双击恢复默认', 'Drag to resize sidebar, double-click to reset'); let currentSide = localStorage.getItem('anomalous_translator_dock_side') || 'right'; bindDrawerResize(resizeHandle, modal, scope, { side: () => currentSide, enabled: () => modal.classList.contains('is-sidebar'), minWidth: 350, setWidth: width => modal.style.setProperty('--amb-translator-width', `${width}px`), saveWidth: width => localStorage.setItem('anomalous_translator_width', String(Math.round(width))), }); resizeHandle.ondblclick = () => { if (!modal.classList.contains('is-sidebar')) return; modal.style.removeProperty('--amb-translator-width'); localStorage.removeItem('anomalous_translator_width'); showTranslatorToast(modal, t('✓ 已恢复默认侧边栏宽度', '✓ Reset to default width')); }; modal.appendChild(resizeHandle); const savedWidth = localStorage.getItem('anomalous_translator_width'); if (savedWidth) { const parsed = parseFloat(savedWidth); const appliedWidth = (!isNaN(parsed) && parsed < 440) ? 460 : parsed; modal.style.setProperty('--amb-translator-width', `${appliedWidth}px`); if (appliedWidth !== parsed) { localStorage.setItem('anomalous_translator_width', String(appliedWidth)); } } // 1. Header const header = document.createElement('div'); header.className = 'anomalous-translator-header'; const titleGroup = document.createElement('div'); titleGroup.className = 'anomalous-translator-title-group'; titleGroup.innerHTML = ` 🌐

${t('翻译助手', 'Translator')}

`; header.appendChild(titleGroup); const headerRight = document.createElement('div'); headerRight.className = 'anomalous-translator-header-right'; // Target Language Selector (will be attached to midBar) const langSelectWrap = document.createElement('label'); langSelectWrap.className = 'anomalous-translator-lang-wrap'; langSelectWrap.innerHTML = `${t('目标语言', 'Target')}:`; const langSelect = document.createElement('select'); langSelect.className = 'anomalous-translator-select'; [ { val: 'auto', labelZh: '⚡ 智能自动互译', labelEn: '⚡ Smart Auto' }, { val: 'en', labelZh: 'English (英文)', labelEn: 'English' }, { val: 'zh-CN', labelZh: '简体中文 (Chinese)', labelEn: 'Chinese (Simplified)' }, { val: 'ja', labelZh: '日本語 (Japanese)', labelEn: 'Japanese' }, { val: 'ko', labelZh: '한국어 (Korean)', labelEn: 'Korean' }, ].forEach(item => { const opt = document.createElement('option'); opt.value = item.val; opt.textContent = window.anomalous_browser_lang === 'zh' ? item.labelZh : item.labelEn; langSelect.appendChild(opt); }); langSelectWrap.appendChild(langSelect); // Dock Side Button (Left / Right) const dockSideBtn = document.createElement('button'); dockSideBtn.type = 'button'; dockSideBtn.className = 'anomalous-translator-mode-btn'; function applyDockSide(side) { currentSide = side; localStorage.setItem('anomalous_translator_dock_side', side); if (side === 'left') { overlay.classList.add('is-dock-left'); modal.classList.add('is-dock-left'); dockSideBtn.innerHTML = `⇥ ${t('靠右', 'Right')}`; dockSideBtn.title = t('切换停靠在屏幕右侧', 'Dock to right side'); } else { overlay.classList.remove('is-dock-left'); modal.classList.remove('is-dock-left'); dockSideBtn.innerHTML = `⇤ ${t('靠左', 'Left')}`; dockSideBtn.title = t('切换停靠在屏幕左侧', 'Dock to left side'); } } dockSideBtn.onclick = () => { applyDockSide(currentSide === 'left' ? 'right' : 'left'); }; headerRight.appendChild(dockSideBtn); // Mode Toggle Button (Sidebar / Modal) let currentMode = localStorage.getItem('anomalous_translator_mode') || 'sidebar'; // Default to sidebar const modeToggleBtn = document.createElement('button'); modeToggleBtn.type = 'button'; modeToggleBtn.className = 'anomalous-translator-mode-btn'; function applyMode(mode) { currentMode = mode; localStorage.setItem('anomalous_translator_mode', mode); if (mode === 'sidebar') { overlay.classList.add('is-sidebar'); modal.classList.add('is-sidebar'); dockSideBtn.style.display = 'inline-flex'; modeToggleBtn.innerHTML = `🔲 ${t('浮窗', 'Modal')}`; modeToggleBtn.title = t('切换为居中弹窗模式', 'Switch to centered modal'); } else { overlay.classList.remove('is-sidebar'); modal.classList.remove('is-sidebar'); dockSideBtn.style.display = 'none'; modeToggleBtn.innerHTML = `📌 ${t('侧栏', 'Sidebar')}`; modeToggleBtn.title = t('切换为贴边侧边栏模式(不遮挡画布,可边点节点边操作)', 'Switch to side drawer mode (no canvas overlay)'); } } modeToggleBtn.onclick = () => { applyMode(currentMode === 'sidebar' ? 'modal' : 'sidebar'); }; headerRight.appendChild(modeToggleBtn); // Close Button const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'anomalous-translator-close'; closeBtn.innerHTML = '×'; closeBtn.title = t('关闭 (Esc)', 'Close (Esc)'); closeBtn.onclick = () => { scope.dispose(); }; headerRight.appendChild(closeBtn); header.appendChild(headerRight); modal.appendChild(header); applyDockSide(currentSide); applyMode(currentMode); if (masterWasVisible && currentMode === 'sidebar') { showTranslatorToast(modal, t('💡 已自动折叠主浏览器,让出全屏画布空间', '💡 Collapsed master browser for canvas space')); } // 2. Body Area (Split pane: Source Input / Translated Output) const body = document.createElement('div'); body.className = 'anomalous-translator-body'; // --- Left / Top Pane: Source Prompt --- const sourcePane = document.createElement('div'); sourcePane.className = 'anomalous-translator-pane'; const sourceLabelBar = document.createElement('div'); sourceLabelBar.className = 'anomalous-translator-pane-label'; sourceLabelBar.innerHTML = `${t('源提示词 (支持中/英文或自然描述)', 'Source Prompt (Text or tags)')}`; sourcePane.appendChild(sourceLabelBar); const sourceTextarea = document.createElement('textarea'); sourceTextarea.className = 'anomalous-translator-textarea'; sourceTextarea.placeholder = t('输入需要翻译的提示词、词组标签或中文画面构思...', 'Enter prompts, comma-separated tags, or Chinese ideas to translate...'); sourcePane.appendChild(sourceTextarea); // Source Action Bar const sourceActions = document.createElement('div'); sourceActions.className = 'anomalous-translator-action-bar'; const sourceLeftGroup = document.createElement('div'); sourceLeftGroup.className = 'anomalous-translator-action-group'; const readNodeBtn = document.createElement('button'); readNodeBtn.type = 'button'; readNodeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; readNodeBtn.innerHTML = `📥 ${t('读取节点', 'Read Node')}`; readNodeBtn.title = t('从 ComfyUI 画布当前选中的节点读取提示词', 'Read prompt from selected canvas node'); readNodeBtn.onclick = () => { if (typeof autoSyncFromNode === 'function') { autoSyncFromNode(null, true); } else { const res = readSelectedNodePrompt(); if (res && res.text) { sourceTextarea.value = res.text; showTranslatorToast(modal, t(`✓ 已读取【${res.nodeTitle}】提示词`, `✓ Read prompt from [${res.nodeTitle}]`)); } else { showTranslatorToast(modal, t('未检测到包含文本的选中节点', 'No text found in selected node'), true); } } }; const sourceCleanBtn = document.createElement('button'); sourceCleanBtn.type = 'button'; sourceCleanBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; sourceCleanBtn.innerHTML = `🧹 ${t('规范化', 'Normalize')}`; sourceCleanBtn.title = t('将源提示词中的顿号、中文标点清洗为标准英文逗号 (, )', 'Normalize source prompt punctuation to standard commas'); sourceCleanBtn.onclick = () => { const raw = sourceTextarea.value.trim(); if (!raw) return; sourceTextarea.value = normalizePromptFormatting(raw); showTranslatorToast(modal, t('✓ 源文本已规范化为标准逗号格式', '✓ Normalized source prompt')); }; const sourceWriteBtn = document.createElement('button'); sourceWriteBtn.type = 'button'; sourceWriteBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; sourceWriteBtn.innerHTML = `✏️ ${t('规范写回', 'Format & Write Back')}`; sourceWriteBtn.title = t('将源文本直接清洗规范后写回选中节点(无需翻译,一键替换节点格式)', 'Normalize source text and write directly back to canvas node'); sourceWriteBtn.onclick = () => { let raw = sourceTextarea.value.trim(); if (!raw) { showTranslatorToast(modal, t('源提示词为空', 'Source prompt is empty'), true); return; } raw = normalizePromptFormatting(raw); sourceTextarea.value = raw; const res = writeToSelectedNode(raw); showTranslatorToast(modal, res.message, !res.success); }; sourceLeftGroup.append(readNodeBtn, sourceCleanBtn, sourceWriteBtn); const sourceRightGroup = document.createElement('div'); sourceRightGroup.className = 'anomalous-translator-action-group'; const clearBtn = document.createElement('button'); clearBtn.type = 'button'; clearBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; clearBtn.textContent = t('清空', 'Clear'); clearBtn.onclick = () => { sourceTextarea.value = ''; sourceTextarea.focus(); }; const translateBtn = document.createElement('button'); translateBtn.type = 'button'; translateBtn.className = 'anomalous-btn-primary anomalous-btn-sm anomalous-translator-btn-run'; translateBtn.innerHTML = `🌐 ${t('翻译', 'Translate')}`; sourceRightGroup.append(clearBtn, translateBtn); sourceActions.append(sourceLeftGroup, sourceRightGroup); sourcePane.appendChild(sourceActions); body.appendChild(sourcePane); // --- Middle Bar: Swap Content & Direction --- const midBar = document.createElement('div'); midBar.className = 'anomalous-translator-mid-bar'; const swapBtn = document.createElement('button'); swapBtn.type = 'button'; swapBtn.className = 'anomalous-translator-swap-btn'; swapBtn.innerHTML = `⇅ ${t('互换', 'Swap')}`; swapBtn.title = t('对调上下两框文本,并反转翻译目标语言', 'Swap source and target text, and invert translation direction'); swapBtn.onclick = () => { const tempText = sourceTextarea.value; sourceTextarea.value = targetTextarea.value; targetTextarea.value = tempText; updateTagChips(targetTextarea.value); if (langSelect.value === 'en') { langSelect.value = 'zh-CN'; } else if (langSelect.value === 'zh-CN') { langSelect.value = 'en'; } showTranslatorToast(modal, t('✓ 已互换源文本与译文', '✓ Swapped source and target')); }; midBar.append(langSelectWrap, swapBtn); body.appendChild(midBar); // --- Right / Bottom Pane: Translated Output --- const targetPane = document.createElement('div'); targetPane.className = 'anomalous-translator-pane'; const targetLabelBar = document.createElement('div'); targetLabelBar.className = 'anomalous-translator-pane-label'; targetLabelBar.innerHTML = `${t('翻译结果与标签预览', 'Translation & Tag Breakdown')}`; targetPane.appendChild(targetLabelBar); const targetTextarea = document.createElement('textarea'); targetTextarea.className = 'anomalous-translator-textarea is-target'; targetTextarea.placeholder = t('译文将在此显示,可直接编辑...', 'Translated text will appear here...'); targetPane.appendChild(targetTextarea); // Tag breakdown chips container const chipsWrap = document.createElement('div'); chipsWrap.className = 'anomalous-translator-chips-wrap'; targetPane.appendChild(chipsWrap); function updateTagChips(text) { chipsWrap.replaceChildren(); const tags = splitPromptTags(text); if (!tags.length) { chipsWrap.style.display = 'none'; return; } chipsWrap.style.display = 'flex'; tags.forEach(tag => { const chip = document.createElement('span'); chip.className = 'anomalous-translator-chip'; chip.textContent = tag; chip.title = t('点击复制此标签', 'Click to copy tag'); chip.onclick = async () => { await navigator.clipboard.writeText(tag); showTranslatorToast(modal, t(`✓ 已复制标签: ${tag}`, `✓ Copied tag: ${tag}`)); }; chipsWrap.appendChild(chip); }); } // Target Action Bar const targetActions = document.createElement('div'); targetActions.className = 'anomalous-translator-action-bar'; const targetLeftGroup = document.createElement('div'); targetLeftGroup.className = 'anomalous-translator-action-group'; const cleanBtn = document.createElement('button'); cleanBtn.type = 'button'; cleanBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; cleanBtn.innerHTML = `🧹 ${t('规范化', 'Normalize')}`; cleanBtn.title = t('将顿号、中文全角逗号等标点统一规范化为标准的英文逗号与空格 (tag, tag)', 'Normalize commas, Chinese enumeration marks, and semicolons to standard tags'); cleanBtn.onclick = () => { const raw = targetTextarea.value.trim(); if (!raw) return; const cleaned = normalizePromptFormatting(raw); targetTextarea.value = cleaned; updateTagChips(cleaned); showTranslatorToast(modal, t('✓ 已规范化为标准标签格式 (, )', '✓ Normalized to standard tags (, )')); }; const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; copyBtn.innerHTML = `📋 ${t('复制', 'Copy')}`; copyBtn.onclick = async () => { const out = targetTextarea.value.trim(); if (!out) return; await navigator.clipboard.writeText(out); showTranslatorToast(modal, t('✓ 译文已复制到剪贴板', '✓ Translation copied to clipboard')); }; const sendToStudioBtn = document.createElement('button'); sendToStudioBtn.type = 'button'; sendToStudioBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; sendToStudioBtn.innerHTML = `🎛️ ${t('工坊', 'Send to Studio')}`; sendToStudioBtn.title = t('将译文发送到提示词工坊拼装组装', 'Send translated text as block to Prompt Studio'); sendToStudioBtn.onclick = () => { let out = targetTextarea.value.trim(); if (!out) return; if (/[,、;;||]/.test(out) && !hasChinese(out)) { out = normalizePromptFormatting(out); } scope.dispose(); if (typeof owner?.openPromptStudio === 'function') { owner.openPromptStudio(); } appendPromptToStudio(owner, out, true, t('翻译结果', 'Translated Prompt')); }; targetLeftGroup.append(cleanBtn, copyBtn, sendToStudioBtn); const targetRightGroup = document.createElement('div'); targetRightGroup.className = 'anomalous-translator-action-group'; // Direct Write to Node button const writeNodeBtn = document.createElement('button'); writeNodeBtn.type = 'button'; writeNodeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; writeNodeBtn.innerHTML = `✏️ ${t('写入节点', 'Write Directly')}`; writeNodeBtn.title = t('将当前文本原样写入选中的画布节点', 'Write current text directly to active ComfyUI node'); // Translate to EN & Write button (The ultimate shortcut for prompt workflows!) const translateAndWriteBtn = document.createElement('button'); translateAndWriteBtn.type = 'button'; translateAndWriteBtn.className = 'anomalous-btn-primary anomalous-btn-sm'; translateAndWriteBtn.innerHTML = `🌐 ${t('反译写入', 'Translate to EN & Write')}`; translateAndWriteBtn.title = t('一键将当前内容反向翻译为规范英文,并直接替换写入选中的画布节点', 'Translate current content into clean English tags and write to canvas node'); translateAndWriteBtn.onclick = async () => { let out = targetTextarea.value.trim(); if (!out) { showTranslatorToast(modal, t('请先输入或翻译文本', 'No text to translate and write'), true); return; } if (translateAndWriteBtn.disabled || scope.signal.aborted) return; // Capture the exact destination before awaiting a translation. const graph = app.graph; const node = selectedMaterialNode(app); const target = node && promptWidgetTargets(node)[0]; if (!target) { showTranslatorToast(modal, t('请先选中可写入的提示词节点', 'Select a writable prompt node first'), true); return; } const widget = node.widgets[target.index]; const previousValue = widget.value; translateAndWriteBtn.disabled = true; translateAndWriteBtn.innerHTML = `⏳ ${t('反译写入中...', 'Translating & Writing...')}`; try { const res = await translatePromptText(out, { targetLang: 'en', signal: scope.signal }); if (scope.signal.aborted) return; if (res.ok && res.translated) { const enTags = normalizePromptFormatting(res.translated); if (app.graph !== graph || selectedMaterialNode(app) !== node || graph.getNodeById(node.id) !== node || node.widgets[target.index] !== widget || widget.value !== previousValue || targetTextarea.value.trim() !== out) { showTranslatorToast(modal, t('目标或文本已改变,请重新点击翻译写入', 'Target or text changed. Translate and write again.'), true); return; } applyNodeMaterialValues(app, node, [{ index: target.index, value: enTags }]); showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!')); sourceTextarea.value = enTags; } else { showTranslatorToast(modal, t(`反译失败: ${res.error || '网络错误'}`, `Translation failed`), true); } } catch (err) { if (scope.signal.aborted) return; showTranslatorToast(modal, t(`反译异常: ${err.message}`, `Error: ${err.message}`), true); } finally { translateAndWriteBtn.disabled = false; translateAndWriteBtn.innerHTML = `🌐 ${t('反译写入', 'Translate to EN & Write')}`; } }; writeNodeBtn.onclick = () => { let out = targetTextarea.value.trim(); if (!out) { showTranslatorToast(modal, t('请先翻译或输入文本', 'No text to write'), true); return; } if (hasChinese(out)) { const confirmEn = confirm(t( '检测到当前文本包含中文。ComfyUI 生图模型通常需要英文提示词。\n\n点击【确定】:自动翻译为英文并规范写入\n点击【取消】:仍直接写入当前中文内容', 'Current text contains Chinese. ComfyUI models usually require English prompts.\n\nClick [OK] to translate to English and write.\nClick [Cancel] to write Chinese directly.' )); if (confirmEn) { translateAndWriteBtn.click(); return; } } if (/[,、;;||]/.test(out) && !hasChinese(out)) { out = normalizePromptFormatting(out); targetTextarea.value = out; updateTagChips(out); } const res = writeToSelectedNode(out); showTranslatorToast(modal, res.message, !res.success); }; targetRightGroup.append(writeNodeBtn, translateAndWriteBtn); targetActions.append(targetLeftGroup, targetRightGroup); targetPane.appendChild(targetActions); body.appendChild(targetPane); modal.appendChild(body); // Translation Handler async function doTranslate() { if (translateBtn.disabled || scope.signal.aborted) return; const raw = sourceTextarea.value.trim(); if (!raw) { sourceTextarea.focus(); return; } const selectedLang = langSelect.value; const previousOutput = targetTextarea.value; const options = { signal: scope.signal }; if (selectedLang !== 'auto') { options.targetLang = selectedLang; } translateBtn.disabled = true; translateBtn.innerHTML = `⏳ ${t('翻译中...', 'Translating...')}`; try { const res = await translatePromptText(raw, options); if (scope.signal.aborted || sourceTextarea.value.trim() !== raw || langSelect.value !== selectedLang || targetTextarea.value !== previousOutput) return; if (res.ok && res.translated) { targetTextarea.value = res.translated; updateTagChips(res.translated); showTranslatorToast(modal, t('✓ 翻译完成', '✓ Translated')); } else { showTranslatorToast(modal, t(`翻译失败: ${res.error || '网络错误'}`, `Failed: ${res.error || 'Network error'}`), true); } } catch (err) { if (scope.signal.aborted) return; showTranslatorToast(modal, t(`翻译异常: ${err.message}`, `Error: ${err.message}`), true); } finally { translateBtn.disabled = false; translateBtn.innerHTML = `🌐 ${t('翻译', 'Translate')}`; } } translateBtn.onclick = doTranslate; sourceTextarea.onkeydown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); doTranslate(); } }; targetTextarea.oninput = () => { updateTagChips(targetTextarea.value); }; let lastLoadedNodePrompt = ''; let isUserEditing = false; sourceTextarea.addEventListener('input', () => { isUserEditing = true; }); function autoSyncFromNode(targetNode = null, isExplicitClick = false) { const promptInfo = readSelectedNodePrompt(targetNode); if (promptInfo && promptInfo.text) { const currentVal = sourceTextarea.value.trim(); if (isExplicitClick || !currentVal || !isUserEditing || currentVal === lastLoadedNodePrompt) { sourceTextarea.value = promptInfo.text; lastLoadedNodePrompt = promptInfo.text; isUserEditing = false; showTranslatorToast(modal, t(`✓ 已读取选中节点【${promptInfo.nodeTitle}】的内容`, `✓ Loaded content from selected node [${promptInfo.nodeTitle}]`)); } } else if (isExplicitClick) { showTranslatorToast(modal, t('未检测到包含提示词文本的选中节点', 'No prompt text found in selected node'), true); } } // Auto-load prompt from selected canvas node immediately autoSyncFromNode(); // Deferred check in case canvas selection settles after modal/button focus setTimeout(() => { if (!sourceTextarea.value.trim()) { autoSyncFromNode(); } }, 60); // Live canvas selection hook while translator is open const canvas = app?.canvas || window?.app?.canvas; if (canvas) { const origOnSelected = canvas.onNodeSelected; const origOnDeselected = canvas.onNodeDeselected; canvas.onNodeSelected = function (node) { if (origOnSelected) origOnSelected.apply(this, arguments); autoSyncFromNode(node); }; canvas.onNodeDeselected = function (node) { if (origOnDeselected) origOnDeselected.apply(this, arguments); }; scope.onDispose(() => { canvas.onNodeSelected = origOnSelected; canvas.onNodeDeselected = origOnDeselected; }); } // Close on clicking backdrop (only active in centered modal mode) overlay.onclick = (e) => { if (e.target === overlay && !overlay.classList.contains('is-sidebar')) { scope.dispose(); } }; // Close on Escape key const onKeyDown = (e) => { if (e.key === 'Escape') { scope.dispose(); } }; scope.listen(document, 'keydown', onKeyDown); document.body.appendChild(overlay); sourceTextarea.focus(); }