/**
* 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();
}