// ==UserScript==
// @name 大模型中文翻译助手
// @name:en LLM powered WebPage Translator to Chinese
// @namespace http://tampermonkey.net/
// @version 2.3.2
// @description 选中文本后调用 OpenAI Compatible API 将其翻译为中文,支持历史记录、收藏夹及整页翻译
// @description:en Select text and call OpenAI Compatible API to translate it to Chinese, supports history, favorites and full page translation
// @author tzh
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/**
* Core Application Architecture
*
* This refactored translator script follows a modular architecture with clear separation of concerns:
* 1. Config - Application settings and configuration management
* 2. State - Global state management with a pub/sub pattern
* 3. API - API service for communication with LLM services
* 4. UI - User interface components
* 5. Utils - Utility functions
* 6. Core - Core application logic and workflow
*/
/**
* Config Module - Manages application settings
*/
const Config = (function() {
// Default settings
const defaultSettings = {
apiEndpoint: 'https://api.deepseek.com/v1/chat/completions',
apiKey: '',
model: 'deepseek-chat',
systemPrompt: '你是一个翻译助手。我会为你提供待翻译的文本,以及之前已经翻译过的上下文(如果有)。请参考这些上下文,将文本准确地翻译成中文,保持原文的意思、风格和格式。在充分保留原文意思的情况下使用符合中文习惯的表达。只返回翻译结果,不需要解释。',
wordExplanationPrompt: '你是一个词汇解释助手。请解释我提供的英语单词或短语。如果是单个单词,请提供音标、多种常见意思、词性分类以及每种意思下的例句。对于短语,请解释其含义和用法,并提供例句。所有内容都需要用中文解释,并使用HTML格式化,以便清晰易读。请为每个例句提供简洁的中文翻译,翻译要准确传达原句含义。返回格式示例:<div class="word-header"><h3>单词或短语</h3><div class="phonetic">/音标/</div></div><div class="meanings"><div class="meaning"><span class="part-of-speech">词性</span>: 意思解释<div class="example">例句<div class="example-translation">例句翻译</div></div></div></div>',
useStreaming: false,
temperature: 0.3,
maxHistoryItems: 50,
maxFavoritesItems: 100,
showSourceLanguage: false,
autoDetectLanguage: true,
detectArticleContent: true,
contextSize: 3,
useTranslationContext: true,
fullPageTranslationSelector: 'body',
fullPageMaxSegmentLength: 2000,
excludeSelectors: 'script, style, noscript, iframe, img, svg, canvas',
apiConfigs: [
{
name: 'DeepSeek',
apiEndpoint: 'https://api.deepseek.com/v1/chat/completions',
apiKey: '',
model: 'deepseek-chat',
}
],
currentApiIndex: 0,
currentTab: 'general',
};
// Current settings
let settings = GM_getValue('translatorSettings', defaultSettings);
// Public methods
return {
// Initialize settings
init: () => {
// Reload settings from storage
settings = GM_getValue('translatorSettings', defaultSettings);
// Sync API settings
Config.syncApiSettings();
return settings;
},
getSettings: () => settings,
getSetting: (key) => {
if(settings[key]){
return settings[key]
}else{
settings[key] = defaultSettings[key];
GM_setValue('translatorSettings', settings);
return defaultSettings[key]
}
},
// Updates a specific setting
updateSetting: (key, value) => {
settings[key] = value;
GM_setValue('translatorSettings', settings);
return settings;
},
// Updates multiple settings at once
updateSettings: (newSettings) => {
settings = { ...settings, ...newSettings };
GM_setValue('translatorSettings', settings);
return settings;
},
// Syncs API settings from the current API config
syncApiSettings: () => {
if (settings.apiConfigs && settings.apiConfigs.length > 0 &&
settings.currentApiIndex >= 0 &&
settings.currentApiIndex < settings.apiConfigs.length) {
const currentApi = settings.apiConfigs[settings.currentApiIndex];
settings.apiEndpoint = currentApi.apiEndpoint;
settings.apiKey = currentApi.apiKey;
settings.model = currentApi.model;
GM_setValue('translatorSettings', settings);
}
}
};
})();
/**
* State Module - Global state management with pub/sub pattern
*/
const State = (function() {
// Private state object
const state = {
translationHistory: GM_getValue('translationHistory', []),
translationFavorites: GM_getValue('translationFavorites', []),
activeTranslateButton: null,
lastSelectedText: '',
lastSelectionRect: null,
isTranslatingFullPage: false,
isTranslationPaused: false,
isStopped: false,
isShowingTranslation: true,
translationSegments: [],
lastTranslatedIndex: -1,
originalTexts: [],
translationCache: GM_getValue('translationCache', {}),
isApplyingCache: false,
cacheApplied: false,
debugMode: true
};
// Store subscribers for each state property
const subscribers = {};
// Store component-specific subscriptions for cleanup
const componentSubscriptions = {};
// Getter for state properties
const get = (key) => {
return state[key];
};
// Setter for state properties
const set = (key, value) => {
// Skip if value hasn't changed
if (state[key] === value) return value;
// Update state
const oldValue = state[key];
state[key] = value;
// Save persistent state to GM storage
if (key === 'translationHistory' || key === 'translationFavorites' || key === 'translationCache') {
GM_setValue(key, value);
}
// Notify subscribers
if (subscribers[key]) {
subscribers[key].forEach(callback => {
try {
callback(value, oldValue);
} catch (err) {
console.error(`Error in state subscriber for ${key}:`, err);
}
});
}
return value;
};
// Subscribe to state changes
const subscribe = (key, callback) => {
if (!subscribers[key]) {
subscribers[key] = [];
}
subscribers[key].push(callback);
// Return unsubscribe function
return () => {
if (subscribers[key]) {
subscribers[key] = subscribers[key].filter(cb => cb !== callback);
}
};
};
// Subscribe to multiple state properties
const subscribeMultiple = (keys, callback) => {
const unsubscribers = keys.map(key => subscribe(key, callback));
// Return a function that unsubscribes from all
return () => {
unsubscribers.forEach(unsubscribe => unsubscribe());
};
};
// Register component subscriptions for easy cleanup
const registerComponent = (componentId) => {
if (!componentSubscriptions[componentId]) {
componentSubscriptions[componentId] = [];
}
return {
subscribe: (key, callback) => {
const unsubscribe = subscribe(key, callback);
componentSubscriptions[componentId].push(unsubscribe);
return unsubscribe;
},
subscribeMultiple: (keys, callback) => {
const unsubscribe = subscribeMultiple(keys, callback);
componentSubscriptions[componentId].push(unsubscribe);
return unsubscribe;
},
cleanup: () => {
if (componentSubscriptions[componentId]) {
componentSubscriptions[componentId].forEach(unsubscribe => unsubscribe());
componentSubscriptions[componentId] = [];
}
}
};
};
// Debug log function
const debugLog = (...args) => {
if (state.debugMode) {
console.log('[Translator]', ...args);
}
};
return {
get,
set,
subscribe,
subscribeMultiple,
registerComponent,
debugLog
};
})();
/**
* API Module - Handles communication with LLM services
*/
const API = (function() {
// Track API errors and delays
let consecutiveErrors = 0;
let currentDelay = 0;
let defaultDelay = 50; // Default delay between API calls
let maxDelay = 5000; // Maximum delay between API calls
// Reset errors when successful
const resetErrorState = () => {
consecutiveErrors = 0;
currentDelay = defaultDelay;
};
// Handle API errors and adjust delay if needed
const handleApiError = (error) => {
consecutiveErrors++;
// Increase delay after multiple consecutive errors (likely rate limiting)
if (consecutiveErrors >= 3) {
// Exponential backoff - increase delay but cap at maximum
currentDelay = Math.min(maxDelay, currentDelay * 1.5 || defaultDelay * 2);
// Append rate limiting information to error
const delayInSeconds = (currentDelay / 1000).toFixed(1);
error.message += ` (已自动增加延迟至${delayInSeconds}秒以减少API负载)`;
// Notify through State for UI to display
State.set('apiDelay', currentDelay);
}
return error;
};
// Wait for the current delay
const applyDelay = async () => {
if (currentDelay > 0) {
await new Promise(resolve => setTimeout(resolve, currentDelay));
}
};
// Translate text using OpenAI-compatible API
const translateText = async (text, options = {}) => {
// Default options
const defaults = {
isWordExplanationMode: false,
useContext: Config.getSetting('useTranslationContext'),
context: null,
retryWithoutStreaming: false,
onProgress: null
};
// Merge defaults with provided options
const settings = { ...defaults, ...options };
// Get configuration
const apiKey = Config.getSetting('apiKey');
const apiEndpoint = Config.getSetting('apiEndpoint');
const model = Config.getSetting('model');
const temperature = Config.getSetting('temperature');
const useStreaming = settings.retryWithoutStreaming ? false : Config.getSetting('useStreaming');
// Validate API key
if (!apiKey) {
throw new Error('API密钥未设置,请在设置面板中配置API密钥');
}
// Prepare prompt based on mode
const systemPrompt = settings.isWordExplanationMode
? Config.getSetting('wordExplanationPrompt')
: Config.getSetting('systemPrompt');
// Prepare messages for the API
const messages = [
{ role: 'system', content: systemPrompt }
];
// Add context messages if available and enabled
if (settings.useContext && settings.context && settings.context.length > 0) {
// Add context messages in pairs (original + translation)
settings.context.forEach(item => {
messages.push({ role: 'user', content: item.source });
messages.push({ role: 'assistant', content: item.translation });
});
}
// Add the current text to translate
messages.push({ role: 'user', content: text });
// Prepare request data
const requestData = {
model: model,
messages: messages,
temperature: parseFloat(temperature),
stream: useStreaming
};
State.debugLog('API Request:', {
endpoint: apiEndpoint,
data: requestData,
streaming: useStreaming
});
// Apply delay before API call if needed
await applyDelay();
try {
let result;
// Handle non-streaming response
if (!useStreaming) {
result = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: apiEndpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
data: JSON.stringify(requestData),
onload: function(response) {
try {
if (response.status >= 200 && response.status < 300) {
const data = JSON.parse(response.responseText);
if (data.choices && data.choices[0] && data.choices[0].message) {
resolve(data.choices[0].message.content);
} else {
reject(new Error('API响应格式不正确,无法获取翻译结果'));
}
} else {
let errorMsg = '翻译请求失败';
try {
const errorData = JSON.parse(response.responseText);
errorMsg = errorData.error?.message || errorMsg;
} catch (e) {
// If parsing fails, use the status text
errorMsg = `翻译请求失败: ${response.statusText}`;
}
reject(new Error(errorMsg));
}
} catch (e) {
reject(new Error(`处理API响应时出错: ${e.message}`));
}
},
onerror: function(error) {
reject(new Error(`API请求出错: ${error.statusText || '未知错误'}`));
},
ontimeout: function() {
reject(new Error('API请求超时'));
}
});
});
} else {
// Handle streaming response
result = await new Promise((resolve, reject) => {
let translatedText = '';
let isFirstChunk = true;
GM_xmlhttpRequest({
method: 'POST',
url: apiEndpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
data: JSON.stringify(requestData),
onloadstart: function() {
State.debugLog('Streaming request started');
},
onprogress: function(response) {
try {
// Parse SSE data
const chunks = response.responseText.split('\n\n');
let newContent = '';
// Process each chunk
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i].trim();
if (!chunk || chunk === 'data: [DONE]') continue;
if (chunk.startsWith('data: ')) {
try {
const data = JSON.parse(chunk.substring(6));
if (data.choices && data.choices[0]) {
const content = data.choices[0].delta?.content || '';
if (content) {
newContent += content;
}
}
} catch (e) {
State.debugLog('Error parsing chunk:', chunk, e);
}
}
}
// Update translated text
translatedText += newContent;
// Call progress callback if provided
if (settings.onProgress && newContent) {
settings.onProgress({
text: translatedText,
isFirstChunk: isFirstChunk
});
isFirstChunk = false;
}
} catch (e) {
State.debugLog('Error processing streaming response:', e);
}
},
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve(translatedText);
} else {
let errorMsg = '翻译请求失败';
try {
const errorData = JSON.parse(response.responseText);
errorMsg = errorData.error?.message || errorMsg;
} catch (e) {
errorMsg = `翻译请求失败: ${response.statusText}`;
}
reject(new Error(errorMsg));
}
},
onerror: function(error) {
reject(new Error(`API请求出错: ${error.statusText || '未知错误'}`));
},
ontimeout: function() {
reject(new Error('API请求超时'));
}
});
});
}
// Reset error state on successful translation
resetErrorState();
return result;
} catch (error) {
// Handle API error and adjust delay
throw handleApiError(error);
}
};
// Retry translation with fallback options
const retryTranslation = async (text, options = {}) => {
try {
return await translateText(text, options);
} catch (error) {
State.debugLog('Translation failed, retrying with fallbacks:', error);
// First fallback: try without streaming if enabled
if (!options.retryWithoutStreaming && Config.getSetting('useStreaming')) {
try {
return await translateText(text, { ...options, retryWithoutStreaming: true });
} catch (streamingError) {
State.debugLog('Retry without streaming failed:', streamingError);
}
}
// Second fallback: try without context if enabled
if (options.useContext && options.context && options.context.length > 0) {
try {
State.debugLog('Retrying without context');
return await translateText(text, {
...options,
useContext: false,
context: null,
retryWithoutStreaming: true
});
} catch (contextError) {
State.debugLog('Retry without context failed:', contextError);
}
}
// Third fallback: try with a different API if available
const apiConfigs = Config.getSetting('apiConfigs');
const currentApiIndex = Config.getSetting('currentApiIndex');
if (apiConfigs.length > 1) {
// Find an alternative API
const alternativeIndex = (currentApiIndex + 1) % apiConfigs.length;
try {
// Temporarily switch API
Config.updateSetting('currentApiIndex', alternativeIndex);
Config.syncApiSettings();
State.debugLog(`Retrying with alternative API: ${apiConfigs[alternativeIndex].name}`);
// Make the request with new API
const result = await translateText(text, {
...options,
retryWithoutStreaming: true // Always use non-streaming for fallback
});
// Switch back to the original API
Config.updateSetting('currentApiIndex', currentApiIndex);
Config.syncApiSettings();
return result;
} catch (apiError) {
// Restore original API settings on error
Config.updateSetting('currentApiIndex', currentApiIndex);
Config.syncApiSettings();
State.debugLog('Retry with alternative API failed:', apiError);
}
}
// All retries failed - throw the original error
throw error;
}
};
// Get current API status for monitoring
const getApiStatus = () => {
return {
consecutiveErrors,
currentDelay,
isRateLimited: consecutiveErrors >= 3
};
};
return {
translateText,
retryTranslation,
getApiStatus
};
})();
/**
* UI Module - User interface components
*/
const UI = (function() {
// UI Components
const components = {
translateButton: {
element: null,
explanationElement: null,
create: (isExplanationMode = false) => {
// Create button if it doesn't exist
if (!components.translateButton.element) {
const button = document.createElement('div');
button.className = 'translate-button';
button.style.cssText = `
position: absolute;
background-color: #4285f4;
color: white;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
z-index: 9999;
user-select: none;
display: flex;
align-items: center;
font-family: Arial, sans-serif;
`;
button.innerHTML = `
<span class="translate-icon" style="margin-right: 6px;">🌐</span>
<span class="translate-text">翻译</span>
`;
// Add click event listener
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const selectedText = State.get('lastSelectedText');
const rect = State.get('lastSelectionRect');
if (selectedText && rect) {
// Set active button
State.set('activeTranslateButton', button);
// Show translation popup
components.translationPopup.show(selectedText, rect, isExplanationMode);
}
});
document.body.appendChild(button);
components.translateButton.element = button;
}
// Update button text based on mode
const textElement = components.translateButton.element.querySelector('.translate-text');
if (textElement) {
textElement.textContent = isExplanationMode ? '解释' : '翻译';
}
return components.translateButton.element;
},
show: (rect) => {
const button = components.translateButton.create();
// Position button near the selection
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
// Position at the end of the selection
let left = rect.right + scrollX;
let top = rect.bottom + scrollY;
// Set position
button.style.left = `${left}px`;
button.style.top = `${top}px`;
button.style.display = 'flex';
// Create word explanation button for short English phrases
const text = State.get('lastSelectedText');
if (Utils.isShortEnglishPhrase(text)) {
if (!components.translateButton.explanationElement) {
const explanationBtn = document.createElement('div');
explanationBtn.className = 'translate-button explanation-button';
explanationBtn.style.cssText = `
position: absolute;
background-color: #fbbc05;
color: white;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
z-index: 9999;
user-select: none;
display: flex;
align-items: center;
font-family: Arial, sans-serif;
`;
explanationBtn.innerHTML = `
<span class="translate-icon" style="margin-right: 6px;">📚</span>
<span class="translate-text">解释</span>
`;
explanationBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Always get the current selected text when explanation button is clicked
const currentText = State.get('lastSelectedText');
const rect = State.get('lastSelectionRect');
components.translationPopup.show(currentText, rect, true);
});
document.body.appendChild(explanationBtn);
components.translateButton.explanationElement = explanationBtn;
}
// Position the explanation button below the main button
const btnRect = button.getBoundingClientRect();
const explanationBtn = components.translateButton.explanationElement;
explanationBtn.style.left = `${left}px`;
explanationBtn.style.top = `${top + btnRect.height + 5}px`;
explanationBtn.style.display = 'flex';
} else if (components.translateButton.explanationElement) {
components.translateButton.explanationElement.style.display = 'none';
}
},
hide: () => {
if (components.translateButton.element) {
components.translateButton.element.style.display = 'none';
}
if (components.translateButton.explanationElement) {
components.translateButton.explanationElement.style.display = 'none';
}
}
},
translationPopup: {
element: null,
create: () => {
if (!components.translationPopup.element) {
const popup = document.createElement('div');
popup.className = 'translation-popup';
popup.style.cssText = `
position: absolute;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 15px;
z-index: 9998;
max-width: 500px;
min-width: 300px;
max-height: 80vh;
overflow-y: auto;
display: none;
font-family: Arial, sans-serif;
line-height: 1.5;
color: #333;
`;
// Create popup header
const header = document.createElement('div');
header.className = 'popup-header';
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
cursor: move;
`;
// Create title
const title = document.createElement('div');
title.className = 'popup-title';
title.style.cssText = 'font-weight: bold; font-size: 16px;';
title.textContent = '翻译结果';
// Create controls
const controls = document.createElement('div');
controls.className = 'popup-controls';
controls.style.cssText = 'display: flex; gap: 8px;';
// Create buttons
const btnStyle = 'background: none; border: none; cursor: pointer; font-size: 16px; padding: 0;';
const favoriteBtn = document.createElement('button');
favoriteBtn.className = 'popup-favorite-btn';
favoriteBtn.innerHTML = '⭐';
favoriteBtn.title = '添加到收藏';
favoriteBtn.style.cssText = btnStyle;
favoriteBtn.addEventListener('click', () => {
const text = components.translationPopup.element.querySelector('.original-text').textContent;
const translation = components.translationPopup.element.querySelector('.translation-text').innerHTML;
Core.favoritesManager.add(text, translation);
favoriteBtn.innerHTML = '✓';
setTimeout(() => { favoriteBtn.innerHTML = '⭐'; }, 1000);
});
const copyBtn = document.createElement('button');
copyBtn.className = 'popup-copy-btn';
copyBtn.innerHTML = '📋';
copyBtn.title = '复制翻译结果';
copyBtn.style.cssText = btnStyle;
copyBtn.addEventListener('click', () => {
const translation = components.translationPopup.element.querySelector('.translation-text').textContent;
navigator.clipboard.writeText(translation);
copyBtn.innerHTML = '✓';
setTimeout(() => { copyBtn.innerHTML = '📋'; }, 1000);
});
const closeBtn = document.createElement('button');
closeBtn.className = 'popup-close-btn';
closeBtn.innerHTML = '✖';
closeBtn.title = '关闭';
closeBtn.style.cssText = btnStyle;
closeBtn.addEventListener('click', () => {
components.translationPopup.hide();
});
// Add buttons to controls
controls.appendChild(favoriteBtn);
controls.appendChild(copyBtn);
controls.appendChild(closeBtn);
// Add title and controls to header
header.appendChild(title);
header.appendChild(controls);
// Create content container
const content = document.createElement('div');
content.className = 'popup-content';
// Create original text area
const originalText = document.createElement('div');
originalText.className = 'original-text';
originalText.style.cssText = `
margin-bottom: 10px;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
display: none;
`;
// Create translation area
const translationText = document.createElement('div');
translationText.className = 'translation-text';
translationText.style.cssText = `
font-size: 16px;
white-space: pre-wrap;
word-break: break-word;
`;
// Create loading animation
const loading = document.createElement('div');
loading.className = 'loading-animation';
loading.style.cssText = 'display: none; text-align: center; padding: 20px 0;';
loading.innerHTML = `
<div style="display: inline-block; width: 30px; height: 30px; border: 3px solid #f3f3f3;
border-top: 3px solid #4285f4; border-radius: 50%; animation: spin 1s linear infinite;"></div>
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
`;
// Create error message area
const errorMsg = document.createElement('div');
errorMsg.className = 'error-message';
errorMsg.style.cssText = 'color: #d93025; font-size: 14px; margin-top: 10px; display: none;';
// Add elements to content
content.appendChild(originalText);
content.appendChild(translationText);
content.appendChild(loading);
content.appendChild(errorMsg);
// Add header and content to popup
popup.appendChild(header);
popup.appendChild(content);
// Add popup to document
document.body.appendChild(popup);
components.translationPopup.element = popup;
// Add draggability
components.translationPopup.makeDraggable(popup, header);
}
return components.translationPopup.element;
},
makeDraggable: (element, handle) => {
let isDragging = false;
let startX, startY;
let startLeft, startTop;
// Function to handle the start of dragging
const onMouseDown = (e) => {
// Ignore if clicked on control buttons
if (e.target.closest('.popup-controls')) {
return;
}
e.preventDefault();
// Get initial positions
isDragging = true;
startX = e.clientX;
startY = e.clientY;
// Get current element position
const rect = element.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
// Add move and up listeners
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
// Change cursor to grabbing
handle.style.cursor = 'grabbing';
};
// Function to handle dragging movement
const onMouseMove = (e) => {
if (!isDragging) return;
e.preventDefault();
// Calculate the new position
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// Apply the new position (considering scroll)
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const newLeft = startLeft + deltaX;
const newTop = startTop + deltaY;
// Set the new position
element.style.left = `${newLeft + scrollX - startX + startLeft}px`;
element.style.top = `${newTop + scrollY - startY + startTop}px`;
};
// Function to handle the end of dragging
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Restore cursor
handle.style.cursor = 'move';
};
// Add mouse down listener to handle
handle.addEventListener('mousedown', onMouseDown);
// Return cleanup function
return () => {
handle.removeEventListener('mousedown', onMouseDown);
};
},
show: async (text, rect, isExplanationMode = false) => {
const popup = components.translationPopup.create();
// Set popup title
const title = popup.querySelector('.popup-title');
title.textContent = isExplanationMode ? '词汇解释' : '翻译结果';
// Set original text
const originalTextElem = popup.querySelector('.original-text');
originalTextElem.textContent = text;
// Show original text if enabled
if (Config.getSetting('showSourceLanguage')) {
originalTextElem.style.display = 'block';
} else {
originalTextElem.style.display = 'none';
}
// Clear previous translation
const translationElem = popup.querySelector('.translation-text');
translationElem.innerHTML = '';
// Apply different styles based on mode
if (isExplanationMode) {
translationElem.style.cssText = `
font-size: 14px;
white-space: normal;
word-break: break-word;
line-height: 1.5;
`;
// Add specific styles for explanation mode content
const style = document.createElement('style');
style.textContent = `
.translation-text .word-header {
margin-bottom: 8px;
}
.translation-text .word-header h3 {
margin: 0 0 4px 0;
font-size: 18px;
}
.translation-text .phonetic {
color: #666;
font-style: italic;
margin-bottom: 8px;
}
.translation-text .meanings {
margin-bottom: 8px;
}
.translation-text .meaning {
margin-bottom: 8px;
}
.translation-text .part-of-speech {
font-weight: bold;
color: #333;
}
.translation-text .example {
margin: 4px 0 4px 12px;
color: #555;
font-style: italic;
}
.translation-text .example-translation {
color: #666;
margin-top: 2px;
}
`;
// Only add the style if it doesn't exist yet
if (!document.querySelector('style#explanation-styles')) {
style.id = 'explanation-styles';
document.head.appendChild(style);
}
} else {
translationElem.style.cssText = `
font-size: 16px;
white-space: pre-wrap;
word-break: break-word;
`;
}
// Show loading animation
const loadingElem = popup.querySelector('.loading-animation');
loadingElem.style.display = 'block';
// Hide error message
const errorElem = popup.querySelector('.error-message');
errorElem.style.display = 'none';
// Position popup
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
let left = rect.left + scrollX;
let top = rect.bottom + scrollY + 10;
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
popup.style.display = 'block';
try {
// Simple progress callback
const onProgress = (data) => {
loadingElem.style.display = 'none';
translationElem.innerHTML = data.text;
};
// Call the API to translate
const translation = await API.retryTranslation(text, {
isWordExplanationMode: isExplanationMode,
onProgress: onProgress
});
// Hide loading and show translation
loadingElem.style.display = 'none';
translationElem.innerHTML = translation;
// Adjust the popup height to not exceed screen height
setTimeout(() => {
const viewportHeight = window.innerHeight;
const popupRect = popup.getBoundingClientRect();
if (popupRect.height > viewportHeight * 0.8) {
popup.style.height = `${viewportHeight * 0.8}px`;
translationElem.style.maxHeight = `${viewportHeight * 0.6}px`;
translationElem.style.overflowY = 'auto';
}
}, 100);
// Add to history
Core.historyManager.add(text, translation);
} catch (error) {
// Hide loading animation
loadingElem.style.display = 'none';
// Show error message
errorElem.textContent = `翻译出错: ${error.message}`;
errorElem.style.display = 'block';
State.debugLog('Translation error:', error);
}
},
hide: () => {
if (components.translationPopup.element) {
components.translationPopup.element.style.display = 'none';
}
// Also hide translate button
components.translateButton.hide();
}
},
pageControls: {
element: null,
progressElement: null,
statusElement: null,
stateManager: null,
create: () => {
if (!components.pageControls.element) {
// Create main panel
const panel = document.createElement('div');
panel.className = 'page-translation-controls';
panel.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
padding: 15px;
z-index: 9999;
font-family: Arial, sans-serif;
display: none;
flex-direction: column;
gap: 10px;
min-width: 220px;
`;
// Create header
const header = document.createElement('div');
header.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-weight: bold;">页面翻译</span>
<button style="background:none; border:none; cursor:pointer; font-size: 16px;">✖</button>
</div>
`;
// Create status element
const statusElement = document.createElement('div');
statusElement.className = 'translation-status';
statusElement.style.cssText = `
font-size: 13px;
color: #666;
margin-bottom: 5px;
display: none;
`;
statusElement.textContent = '准备翻译...';
// Create progress bar
const progressBar = document.createElement('div');
progressBar.style.cssText = 'background:#f0f0f0; height:6px; margin:5px 0; border-radius:3px;';
const progressIndicator = document.createElement('div');
progressIndicator.style.cssText = 'background:#4285f4; height:100%; width:0%; transition:width 0.3s;';
progressBar.appendChild(progressIndicator);
const progressText = document.createElement('div');
progressText.innerHTML = '<span>翻译进度</span><span class="progress-percentage">0%</span>';
progressText.style.cssText = 'display:flex; justify-content:space-between; font-size:12px;';
// Create buttons
const buttons = document.createElement('div');
buttons.style.cssText = 'display:flex; gap:8px; margin-top:10px;';
const pauseBtn = document.createElement('button');
pauseBtn.textContent = '暂停';
pauseBtn.style.cssText = 'flex:1; padding:8px; background:#f5f5f5; border:none; border-radius:4px; cursor:pointer;';
const stopBtn = document.createElement('button');
stopBtn.textContent = '停止';
stopBtn.style.cssText = 'flex:1; padding:8px; background:#ff5252; color:white; border:none; border-radius:4px; cursor:pointer;';
const restoreBtn = document.createElement('button');
restoreBtn.textContent = '恢复原文';
restoreBtn.style.cssText = 'flex:1; padding:8px; background:#f5f5f5; border:none; border-radius:4px; cursor:pointer;';
buttons.appendChild(pauseBtn);
buttons.appendChild(stopBtn);
buttons.appendChild(restoreBtn);
// Create secondary buttons
const secondaryButtons = document.createElement('div');
secondaryButtons.style.cssText = 'display:flex; gap:8px; margin-top:8px;';
const retranslateBtn = document.createElement('button');
retranslateBtn.textContent = '重新翻译';
retranslateBtn.title = '忽略缓存,重新翻译整个页面';
retranslateBtn.style.cssText = 'flex:1; padding:8px; background:#5cb85c; color:white; border:none; border-radius:4px; cursor:pointer;';
secondaryButtons.appendChild(retranslateBtn);
// Create statistics element
const statsElement = document.createElement('div');
statsElement.className = 'translation-stats';
statsElement.style.cssText = `
font-size: 12px;
color: #666;
margin-top: 8px;
`;
// Add all elements to panel
panel.appendChild(header);
panel.appendChild(statusElement);
panel.appendChild(progressText);
panel.appendChild(progressBar);
panel.appendChild(buttons);
panel.appendChild(secondaryButtons);
panel.appendChild(statsElement);
// Add event listeners
header.querySelector('button').addEventListener('click', () => {
Core.restoreOriginalText(true);
components.pageControls.hide();
});
pauseBtn.addEventListener('click', () => {
const isPaused = State.get('isTranslationPaused');
State.set('isTranslationPaused', !isPaused);
});
stopBtn.addEventListener('click', () => {
if (State.get('isTranslatingFullPage')) {
Core.stopTranslation();
}
});
restoreBtn.addEventListener('click', () => {
const isShowingTranslation = State.get('isShowingTranslation');
State.set('isShowingTranslation', !isShowingTranslation);
if (isShowingTranslation) {
Core.restoreOriginalText(false);
} else {
Core.showTranslation();
}
});
retranslateBtn.addEventListener('click', () => {
if (!State.get('isTranslatingFullPage')) {
// Show confirmation dialog if we have cached translations
const segments = State.get('translationSegments');
const hasCachedTranslations = segments && segments.length > 0 && segments.some(s => s.fromCache);
if (hasCachedTranslations) {
if (confirm('确定要忽略缓存重新翻译整个页面吗?这可能需要更长时间。')) {
Core.translateFullPage({ forceRetranslate: true });
}
} else {
Core.translateFullPage({ forceRetranslate: true });
}
}
});
// Make panel draggable
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
const headerElement = header.querySelector('div');
headerElement.style.cursor = 'move';
headerElement.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
isDragging = true;
const rect = panel.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', stopDrag);
});
const handleDrag = e => {
if (!isDragging) return;
const x = e.clientX - dragOffsetX;
const y = e.clientY - dragOffsetY;
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
panel.style.right = 'auto';
};
const stopDrag = () => {
isDragging = false;
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
};
// Store references
components.pageControls.element = panel;
components.pageControls.progressElement = {
indicator: progressIndicator,
percentage: progressText.querySelector('.progress-percentage'),
pauseButton: pauseBtn,
stopButton: stopBtn,
restoreButton: restoreBtn
};
components.pageControls.statusElement = statusElement;
components.pageControls.statsElement = statsElement;
document.body.appendChild(panel);
}
return components.pageControls.element;
},
setupStateSubscriptions: () => {
// Clear any previous subscriptions
if (components.pageControls.stateManager) {
components.pageControls.stateManager.cleanup();
}
// Create new state manager for this component
const stateManager = State.registerComponent('pageControls');
components.pageControls.stateManager = stateManager;
// Subscribe to translation paused state
stateManager.subscribe('isTranslationPaused', isPaused => {
const { pauseButton } = components.pageControls.progressElement;
const statusElement = components.pageControls.statusElement;
// Only update if translation is in progress
if (State.get('isTranslatingFullPage')) {
if (isPaused) {
pauseButton.textContent = '继续';
statusElement.textContent = '翻译已暂停';
} else {
pauseButton.textContent = '暂停';
const index = State.get('lastTranslatedIndex');
const segments = State.get('translationSegments');
if (segments && segments.length > 0) {
statusElement.textContent = `正在翻译 (${index + 1}/${segments.length})`;
// If paused, resume translation
if (index >= 0 && index < segments.length - 1) {
Core.translateNextSegment(index + 1);
}
}
}
} else {
// Translation is not in progress, ensure button is in correct state
pauseButton.disabled = true;
pauseButton.textContent = '暂停';
}
});
// Subscribe to translation progress
stateManager.subscribe('lastTranslatedIndex', index => {
const segments = State.get('translationSegments');
if (!segments || segments.length === 0) return;
// 精确计算已翻译的段落,确保包括所有已处理的段落
const translatedCount = segments.filter(s => s.translation || s.error).length;
// 如果翻译已经完成,强制显示100%
let progress, percent;
if (!State.get('isTranslatingFullPage') && !State.get('isTranslationPaused')) {
// 翻译已完成状态,显示100%
progress = 1;
percent = 100;
} else {
// 正常计算进度
progress = translatedCount / segments.length;
percent = Math.round(progress * 100);
}
// Update progress bar
const { indicator, percentage } = components.pageControls.progressElement;
indicator.style.width = `${percent}%`;
percentage.textContent = `${percent}% (${translatedCount}/${segments.length})`;
// Update status text based on translated count
if (!State.get('isTranslationPaused')) {
if (!State.get('isTranslatingFullPage')) {
components.pageControls.statusElement.textContent = `翻译完成`;
} else {
components.pageControls.statusElement.textContent = `正在翻译 (${translatedCount}/${segments.length})`;
}
}
// Update stats
components.pageControls.updateStats(segments);
});
// Subscribe to translation state changes
stateManager.subscribe('isTranslatingFullPage', isTranslating => {
const { pauseButton, stopButton, restoreButton } = components.pageControls.progressElement;
const statusElement = components.pageControls.statusElement;
const controlsPanel = components.pageControls.element;
if (!controlsPanel) return; // Safety check
const retranslateBtn = controlsPanel.querySelector('button[title="忽略缓存,重新翻译整个页面"]');
// Update button states
pauseButton.disabled = !isTranslating;
stopButton.disabled = !isTranslating;
if (retranslateBtn) {
retranslateBtn.disabled = isTranslating;
retranslateBtn.style.opacity = isTranslating ? '0.5' : '1';
}
// If stopping/completing translation
if (!isTranslating) {
pauseButton.disabled = true;
stopButton.disabled = true;
restoreButton.disabled = false;
// Reset pause state when translation completes
if (State.get('isTranslationPaused')) {
State.set('isTranslationPaused', false);
}
if (State.get('isStopped')) {
statusElement.textContent = '翻译已停止';
} else {
statusElement.textContent = '翻译完成';
statusElement.style.color = '#4CAF50';
}
// Final stats update
const segments = State.get('translationSegments');
if (segments && segments.length > 0) {
components.pageControls.updateStats(segments);
}
}
});
// Subscribe to showing translation state
stateManager.subscribe('isShowingTranslation', isShowing => {
const { restoreButton } = components.pageControls.progressElement;
restoreButton.textContent = isShowing ? '恢复原文' : '显示译文';
});
// Subscribe to stopped state
stateManager.subscribe('isStopped', isStopped => {
if (isStopped) {
components.pageControls.statusElement.textContent = '翻译已停止';
components.pageControls.statusElement.style.color = '';
}
});
// Subscribe to API delay changes
stateManager.subscribe('apiDelay', delay => {
if (delay > 0) {
const delaySeconds = (delay / 1000).toFixed(1);
components.pageControls.statusElement.textContent =
`延迟增加至${delaySeconds}秒(API限流保护)`;
}
});
},
show: () => {
const panel = components.pageControls.create();
panel.style.display = 'flex';
// Reset progress UI elements
const statusElement = components.pageControls.statusElement;
statusElement.style.display = 'block';
statusElement.textContent = '准备翻译...';
statusElement.style.color = '';
// Reset progress bar
const { indicator, percentage, pauseButton, stopButton } = components.pageControls.progressElement;
indicator.style.width = '0%';
percentage.textContent = '0% (0/0)';
// Reset pause and stop button states
pauseButton.textContent = '暂停';
pauseButton.disabled = false;
stopButton.disabled = false;
// Clear stats
if (components.pageControls.statsElement) {
components.pageControls.statsElement.textContent = '';
}
// Set up state subscriptions
components.pageControls.setupStateSubscriptions();
// Reset translation states in the UI
State.set('isShowingTranslation', true);
},
hide: () => {
if (components.pageControls.element) {
components.pageControls.element.style.display = 'none';
// Clean up subscriptions to prevent memory leaks
if (components.pageControls.stateManager) {
components.pageControls.stateManager.cleanup();
}
}
},
updateStats: segments => {
if (!components.pageControls.statsElement) return;
// Count successes, errors, and pending
let success = 0;
let error = 0;
let pending = 0;
let cached = 0;
segments.forEach(segment => {
if (segment.translation && !segment.error) {
if (segment.fromCache) {
cached++;
} else {
success++;
}
} else if (segment.error) {
error++;
} else {
// 段落无翻译也无错误时,视为等待中
if (State.get('isTranslatingFullPage') && !State.get('isStopped')) {
pending++;
}
}
});
// 确保显示总数的准确性
const total = success + cached + error + pending;
// Only show non-zero values
let stats = [];
if (success) stats.push(`${success} 翻译成功`);
if (cached) stats.push(`${cached} 来自缓存`);
if (error) stats.push(`${error} 失败`);
if (pending) stats.push(`${pending} 等待中`);
// 添加完成比例
if (segments.length > 0) {
const completedPercent = Math.round((success + cached + error) / segments.length * 100);
stats.push(`总完成率 ${completedPercent}%`);
}
// If translation is complete/stopped but no stats, show a default message
if (stats.length === 0 && !State.get('isTranslatingFullPage')) {
stats.push('翻译已完成');
}
components.pageControls.statsElement.textContent = stats.join(' · ');
}
},
settingsPanel: {
element: null,
apiForm: null,
create: () => {
if (!components.settingsPanel.element) {
// Create panel
const panel = document.createElement('div');
panel.className = 'translator-settings-panel';
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 500px;
max-width: 90%;
background: white;
box-shadow: 0 0 20px rgba(0,0,0,0.3);
border-radius: 8px;
z-index: 10000;
font-family: Arial, sans-serif;
display: none;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
`;
// Create tabs
const tabsContainer = document.createElement('div');
tabsContainer.style.cssText = 'display: flex; border-bottom: 1px solid #eee;';
const generalTab = document.createElement('button');
generalTab.textContent = '翻译设置';
generalTab.dataset.tab = 'general';
generalTab.style.cssText = 'flex: 1; padding: 12px; border: none; background: none; cursor: pointer; border-bottom: 2px solid #4285f4; color: #4285f4;';
const apiTab = document.createElement('button');
apiTab.textContent = 'API 管理';
apiTab.dataset.tab = 'api';
apiTab.style.cssText = 'flex: 1; padding: 12px; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent;';
tabsContainer.appendChild(generalTab);
tabsContainer.appendChild(apiTab);
panel.appendChild(tabsContainer);
// Create content container
const contentContainer = document.createElement('div');
contentContainer.style.cssText = 'flex: 1; overflow-y: auto;';
// Create general settings content
const generalContent = document.createElement('div');
generalContent.dataset.tabContent = 'general';
generalContent.style.cssText = 'display: block; padding: 20px;';
generalContent.innerHTML = `
<h3 style="margin-top: 0; margin-bottom: 15px;">通用设置</h3>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">系统提示词:</label>
<textarea id="setting-systemPrompt" style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit;"></textarea>
<div style="font-size: 12px; color: #666; margin-top: 5px;">用于指导翻译模型如何翻译文本</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">单词解释提示词:</label>
<textarea id="setting-wordExplanationPrompt" style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit;"></textarea>
<div style="font-size: 12px; color: #666; margin-top: 5px;">用于指导如何解释单词或短语</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: flex; align-items: center;">
<input type="checkbox" id="setting-showSourceLanguage">
<span style="margin-left: 8px;">显示原文</span>
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">启用后将在翻译结果上方显示原文</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: flex; align-items: center;">
<input type="checkbox" id="setting-useStreaming">
<span style="margin-left: 8px;">启用流式响应(实时显示翻译)</span>
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">如果遇到翻译失败问题,可以尝试关闭此选项</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: flex; align-items: center;">
<input type="checkbox" id="setting-useTranslationContext">
<span style="margin-left: 8px;">启用翻译上下文</span>
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">启用后将使用之前翻译过的内容作为上下文,提高翻译连贯性</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">上下文数量:</label>
<input type="number" id="setting-contextSize" min="1" max="10" style="width: 60px; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;">
<div style="font-size: 12px; color: #666; margin-top: 5px;">使用前面已翻译段落作为上下文提升翻译连贯性,建议设置1-5之间</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">随机性(Temperature):</label>
<div style="display: flex; align-items: center;">
<input type="range" id="setting-temperature" min="0" max="1" step="0.1" style="flex: 1;">
<span id="temperature-value" style="margin-left: 10px; min-width: 30px; text-align: right;"></span>
</div>
<div style="font-size: 12px; color: #666; margin-top: 5px;">值越低翻译越准确,值越高结果越有创意</div>
</div>
<h3 style="margin-top: 25px; margin-bottom: 15px;">整页翻译设置</h3>
<div style="margin-bottom: 15px;">
<label style="display: flex; align-items: center;">
<input type="checkbox" id="setting-detectArticleContent">
<span style="margin-left: 8px;">智能识别文章主体内容</span>
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">启用后将自动识别文章主要内容区域,避免翻译无关内容</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">整页翻译选择器:</label>
<input type="text" id="setting-fullPageTranslationSelector" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<div style="font-size: 12px; color: #666; margin-top: 5px;">CSS选择器,用于指定翻译哪些区域的内容</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">排除翻译的元素:</label>
<input type="text" id="setting-excludeSelectors" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<div style="font-size: 12px; color: #666; margin-top: 5px;">CSS选择器,指定要排除翻译的元素</div>
</div>
`;
// Create API settings content
const apiContent = document.createElement('div');
apiContent.dataset.tabContent = 'api';
apiContent.style.cssText = 'display: none; padding: 20px;';
apiContent.innerHTML = '<h3 style="margin-top: 0;">API 设置</h3>';
// Create API list container
const apiListContainer = document.createElement('div');
apiListContainer.id = 'api-list-container';
// Create "Add API" button
const addApiButton = document.createElement('button');
addApiButton.textContent = '+ 添加新API';
addApiButton.style.cssText = 'width: 100%; padding: 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 15px;';
addApiButton.addEventListener('click', () => {
components.settingsPanel.showApiForm();
});
apiContent.appendChild(addApiButton);
apiContent.appendChild(apiListContainer);
// Add content to container
contentContainer.appendChild(generalContent);
contentContainer.appendChild(apiContent);
panel.appendChild(contentContainer);
// Create footer with buttons
const footer = document.createElement('div');
footer.style.cssText = 'padding: 15px 20px; border-top: 1px solid #eee; text-align: right;';
const cancelButton = document.createElement('button');
cancelButton.textContent = '取消';
cancelButton.style.cssText = 'margin-right: 10px; padding: 8px 16px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;';
cancelButton.addEventListener('click', () => {
components.settingsPanel.hide();
});
const saveButton = document.createElement('button');
saveButton.textContent = '保存';
saveButton.style.cssText = 'padding: 8px 16px; background: #4285f4; color: white; border: none; border-radius: 4px; cursor: pointer;';
saveButton.addEventListener('click', () => {
// Get form values from general tab
const newSettings = {
systemPrompt: generalContent.querySelector('#setting-systemPrompt').value,
wordExplanationPrompt: generalContent.querySelector('#setting-wordExplanationPrompt').value,
showSourceLanguage: generalContent.querySelector('#setting-showSourceLanguage').checked,
useStreaming: generalContent.querySelector('#setting-useStreaming').checked,
useTranslationContext: generalContent.querySelector('#setting-useTranslationContext').checked,
contextSize: parseInt(generalContent.querySelector('#setting-contextSize').value) || 3,
temperature: parseFloat(generalContent.querySelector('#setting-temperature').value),
detectArticleContent: generalContent.querySelector('#setting-detectArticleContent').checked,
fullPageTranslationSelector: generalContent.querySelector('#setting-fullPageTranslationSelector').value,
excludeSelectors: generalContent.querySelector('#setting-excludeSelectors').value
};
// Update settings
Config.updateSettings(newSettings);
// Sync API settings if needed
Config.syncApiSettings();
// Hide panel
components.settingsPanel.hide();
});
footer.appendChild(cancelButton);
footer.appendChild(saveButton);
panel.appendChild(footer);
// Add tab switching event listeners
[generalTab, apiTab].forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
// Update tab styling
[generalTab, apiTab].forEach(t => {
if (t.dataset.tab === tabName) {
t.style.borderBottom = '2px solid #4285f4';
t.style.color = '#4285f4';
} else {
t.style.borderBottom = '2px solid transparent';
t.style.color = 'inherit';
}
});
// Show/hide content
contentContainer.querySelectorAll('[data-tab-content]').forEach(content => {
if (content.dataset.tabContent === tabName) {
content.style.display = 'block';
} else {
content.style.display = 'none';
}
});
// Update API list if showing API tab
if (tabName === 'api') {
components.settingsPanel.updateApiList();
}
});
});
// Temperature slider
const temperatureSlider = generalContent.querySelector('#setting-temperature');
const temperatureValue = generalContent.querySelector('#temperature-value');
temperatureSlider.addEventListener('input', () => {
temperatureValue.textContent = temperatureSlider.value;
});
// Store reference
components.settingsPanel.element = panel;
document.body.appendChild(panel);
}
return components.settingsPanel.element;
},
createApiForm: () => {
if (!components.settingsPanel.apiForm) {
const form = document.createElement('div');
form.className = 'api-form';
form.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: white;
z-index: 1;
display: none;
flex-direction: column;
`;
// Form header
const header = document.createElement('div');
header.style.cssText = 'padding: 15px 20px; border-bottom: 1px solid #eee;';
const title = document.createElement('h3');
title.id = 'api-form-title';
title.textContent = '添加API';
title.style.margin = '0';
header.appendChild(title);
form.appendChild(header);
// Form content
const content = document.createElement('div');
content.style.cssText = 'flex: 1; overflow-y: auto; padding: 20px;';
// Hidden index field for editing
const indexField = document.createElement('input');
indexField.type = 'hidden';
indexField.id = 'api-form-index';
indexField.value = '-1';
// Form fields
content.innerHTML = `
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">API 名称:</label>
<input type="text" id="api-name" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="例如:OpenAI、Azure、DeepSeek">
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">API 端点:</label>
<input type="text" id="api-endpoint" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="例如:https://api.openai.com/v1/chat/completions">
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">API 密钥:</label>
<input type="password" id="api-key" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="输入您的API密钥">
<div style="font-size: 12px; color: #666; margin-top: 5px;">编辑现有API时,如不需要修改密钥请留空</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">模型名称:</label>
<input type="text" id="api-model" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="例如:gpt-3.5-turbo">
</div>
`;
content.insertBefore(indexField, content.firstChild);
form.appendChild(content);
// Form footer
const footer = document.createElement('div');
footer.style.cssText = 'padding: 15px 20px; border-top: 1px solid #eee; text-align: right;';
const cancelButton = document.createElement('button');
cancelButton.textContent = '取消';
cancelButton.style.cssText = 'margin-right: 10px; padding: 8px 16px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;';
cancelButton.addEventListener('click', () => {
components.settingsPanel.hideApiForm();
});
const saveButton = document.createElement('button');
saveButton.textContent = '保存';
saveButton.style.cssText = 'padding: 8px 16px; background: #4285f4; color: white; border: none; border-radius: 4px; cursor: pointer;';
saveButton.addEventListener('click', () => {
// Get form values
const index = parseInt(indexField.value);
const name = content.querySelector('#api-name').value.trim();
const endpoint = content.querySelector('#api-endpoint').value.trim();
const key = content.querySelector('#api-key').value.trim();
const model = content.querySelector('#api-model').value.trim();
// Validate inputs
if (!name || !endpoint || !model) {
alert('请填写所有必填字段');
return;
}
// Get current API configs
const apiConfigs = Config.getSetting('apiConfigs');
// Create new API config
const apiConfig = {
name,
apiEndpoint: endpoint,
model
};
// Only update API key if provided
if (key) {
apiConfig.apiKey = key;
} else if (index !== -1) {
// Keep existing key when editing
apiConfig.apiKey = apiConfigs[index].apiKey;
} else {
// New API must have a key
alert('请提供API密钥');
return;
}
// Add or update API config
if (index === -1) {
// Add new API
apiConfigs.push(apiConfig);
} else {
// Update existing API
apiConfigs[index] = apiConfig;
}
// Update settings
Config.updateSetting('apiConfigs', apiConfigs);
// Hide form
components.settingsPanel.hideApiForm();
// Update API list
components.settingsPanel.updateApiList();
});
footer.appendChild(cancelButton);
footer.appendChild(saveButton);
form.appendChild(footer);
// Store reference
components.settingsPanel.apiForm = form;
components.settingsPanel.element.appendChild(form);
}
return components.settingsPanel.apiForm;
},
show: () => {
const panel = components.settingsPanel.create();
// Get current settings
const settings = Config.getSettings();
// Update general settings form
const generalContent = panel.querySelector('[data-tab-content="general"]');
generalContent.querySelector('#setting-systemPrompt').value = settings.systemPrompt;
generalContent.querySelector('#setting-wordExplanationPrompt').value = settings.wordExplanationPrompt;
generalContent.querySelector('#setting-showSourceLanguage').checked = settings.showSourceLanguage;
generalContent.querySelector('#setting-useStreaming').checked = settings.useStreaming;
generalContent.querySelector('#setting-useTranslationContext').checked = settings.useTranslationContext;
generalContent.querySelector('#setting-contextSize').value = settings.contextSize;
generalContent.querySelector('#setting-temperature').value = settings.temperature;
generalContent.querySelector('#temperature-value').textContent = settings.temperature;
generalContent.querySelector('#setting-detectArticleContent').checked = settings.detectArticleContent;
generalContent.querySelector('#setting-fullPageTranslationSelector').value = settings.fullPageTranslationSelector;
generalContent.querySelector('#setting-excludeSelectors').value = settings.excludeSelectors;
// Update API list
components.settingsPanel.updateApiList();
// Show panel
panel.style.display = 'flex';
},
hide: () => {
if (components.settingsPanel.element) {
components.settingsPanel.element.style.display = 'none';
}
// Also hide API form if open
components.settingsPanel.hideApiForm();
},
showApiForm: (editIndex = -1) => {
// Create API form if it doesn't exist
const form = components.settingsPanel.createApiForm();
// Set form title
const title = form.querySelector('#api-form-title');
title.textContent = editIndex === -1 ? '添加API' : '编辑API';
// Set hidden index field
const indexField = form.querySelector('#api-form-index');
indexField.value = editIndex;
// Clear form fields
form.querySelector('#api-name').value = '';
form.querySelector('#api-endpoint').value = '';
form.querySelector('#api-key').value = '';
form.querySelector('#api-model').value = '';
// Fill form fields if editing
if (editIndex !== -1) {
const apiConfigs = Config.getSetting('apiConfigs');
const api = apiConfigs[editIndex];
form.querySelector('#api-name').value = api.name;
form.querySelector('#api-endpoint').value = api.apiEndpoint;
form.querySelector('#api-model').value = api.model;
}
// Show form
form.style.display = 'flex';
},
hideApiForm: () => {
if (components.settingsPanel.apiForm) {
components.settingsPanel.apiForm.style.display = 'none';
}
},
updateApiList: () => {
const panel = components.settingsPanel.element;
if (!panel) return;
const apiListContainer = panel.querySelector('#api-list-container');
if (!apiListContainer) return;
// Clear existing content
apiListContainer.innerHTML = '';
// Get API configs
const apiConfigs = Config.getSetting('apiConfigs');
const currentApiIndex = Config.getSetting('currentApiIndex');
// No APIs
if (apiConfigs.length === 0) {
apiListContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无API配置</div>';
return;
}
// Create API items
apiConfigs.forEach((api, index) => {
const isActive = index === currentApiIndex;
const item = document.createElement('div');
item.className = 'api-item';
item.style.cssText = `
margin-bottom: 15px;
padding: 15px;
border: 1px solid ${isActive ? '#4285f4' : '#ddd'};
border-radius: 4px;
position: relative;
background-color: ${isActive ? '#f0f8ff' : 'white'};
`;
// API info
item.innerHTML = `
<div style="margin-bottom: 8px;"><strong>名称:</strong> <span>${api.name}</span></div>
<div style="margin-bottom: 8px;"><strong>端点:</strong> <span>${api.apiEndpoint}</span></div>
<div style="margin-bottom: 8px;"><strong>密钥:</strong> <span>${api.apiKey ? '******' + api.apiKey.substring(api.apiKey.length - 4) : '未设置'}</span></div>
<div><strong>模型:</strong> <span>${api.model}</span></div>
`;
// Buttons container
const buttons = document.createElement('div');
buttons.style.cssText = 'position: absolute; top: 15px; right: 15px;';
// Add button for setting as active (if not already active)
if (!isActive) {
const useButton = document.createElement('button');
useButton.textContent = '使用';
useButton.style.cssText = 'margin-right: 8px; padding: 4px 8px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer;';
useButton.addEventListener('click', () => {
Config.updateSetting('currentApiIndex', index);
Config.syncApiSettings();
components.settingsPanel.updateApiList();
});
buttons.appendChild(useButton);
} else {
const activeLabel = document.createElement('span');
activeLabel.textContent = '✓ 当前使用';
activeLabel.style.cssText = 'color: #4CAF50; font-weight: 500; margin-right: 8px;';
buttons.appendChild(activeLabel);
}
// Edit button
const editButton = document.createElement('button');
editButton.textContent = '编辑';
editButton.style.cssText = 'margin-right: 8px; padding: 4px 8px; background-color: #2196F3; color: white; border: none; border-radius: 3px; cursor: pointer;';
editButton.addEventListener('click', () => {
components.settingsPanel.showApiForm(index);
});
buttons.appendChild(editButton);
// Delete button (only if there are multiple APIs)
if (apiConfigs.length > 1) {
const deleteButton = document.createElement('button');
deleteButton.textContent = '删除';
deleteButton.style.cssText = 'padding: 4px 8px; background-color: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer;';
deleteButton.addEventListener('click', () => {
if (confirm('确定要删除此API配置吗?')) {
// Remove API config
apiConfigs.splice(index, 1);
// Update current index if needed
if (currentApiIndex >= apiConfigs.length) {
Config.updateSetting('currentApiIndex', apiConfigs.length - 1);
} else if (index === currentApiIndex) {
Config.updateSetting('currentApiIndex', 0);
}
// Update settings
Config.updateSetting('apiConfigs', apiConfigs);
Config.syncApiSettings();
// Update API list
components.settingsPanel.updateApiList();
}
});
buttons.appendChild(deleteButton);
}
item.appendChild(buttons);
apiListContainer.appendChild(item);
});
}
},
historyPanel: {
element: null,
visible: false,
create: () => {
if (!components.historyPanel.element) {
// Create panel
const panel = document.createElement('div');
panel.className = 'translator-history-panel';
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 500px;
max-width: 90%;
max-height: 90vh;
background: white;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0,0,0,0.3);
z-index: 10000;
display: none;
flex-direction: column;
font-family: Arial, sans-serif;
overflow: hidden;
`;
// Header
const header = document.createElement('div');
header.style.cssText = `
padding: 15px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
`;
const title = document.createElement('h3');
title.textContent = '翻译历史';
title.style.margin = '0';
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✖';
closeBtn.style.cssText = 'background: none; border: none; font-size: 16px; cursor: pointer;';
closeBtn.addEventListener('click', () => components.historyPanel.hide());
header.appendChild(title);
header.appendChild(closeBtn);
panel.appendChild(header);
// Content
const content = document.createElement('div');
content.className = 'history-items';
content.style.cssText = 'flex: 1; overflow-y: auto; padding: 0 15px; max-height: 70vh;';
panel.appendChild(content);
// Footer
const footer = document.createElement('div');
footer.style.cssText = 'padding: 10px 15px; border-top: 1px solid #eee; text-align: right;';
const clearBtn = document.createElement('button');
clearBtn.textContent = '清空历史';
clearBtn.style.cssText = 'padding: 8px 16px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;';
clearBtn.addEventListener('click', () => {
if (confirm('确定要清空所有历史记录吗?')) {
Core.historyManager.clear();
components.historyPanel.update();
}
});
footer.appendChild(clearBtn);
panel.appendChild(footer);
// Add to document
document.body.appendChild(panel);
components.historyPanel.element = panel;
}
return components.historyPanel.element;
},
show: () => {
const panel = components.historyPanel.create();
components.historyPanel.update();
panel.style.display = 'flex';
components.historyPanel.visible = true;
},
hide: () => {
if (components.historyPanel.element) {
components.historyPanel.element.style.display = 'none';
components.historyPanel.visible = false;
}
},
isVisible: () => components.historyPanel.visible,
update: () => {
const panel = components.historyPanel.element;
if (!panel) return;
const content = panel.querySelector('.history-items');
if (!content) return;
// Clear content
content.innerHTML = '';
// Get history
const history = State.get('translationHistory');
if (history.length === 0) {
content.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无历史记录</div>';
return;
}
// Create items in reverse order (newest first)
for (let i = history.length - 1; i >= 0; i--) {
const item = history[i];
const date = new Date(item.timestamp);
const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.style.cssText = 'padding: 15px 0; border-bottom: 1px solid #eee; position: relative;';
historyItem.innerHTML = `
<div style="color: #999; font-size: 12px; margin-bottom: 5px;">${dateStr}</div>
<div style="margin-bottom: 8px; font-weight: bold;">${item.source}</div>
<div>${item.translation}</div>
`;
// Add to favorites button
const favButton = document.createElement('button');
favButton.innerHTML = '⭐';
favButton.title = '添加到收藏';
favButton.style.cssText = 'position: absolute; top: 15px; right: 0; background: none; border: none; font-size: 16px; cursor: pointer;';
favButton.addEventListener('click', () => {
Core.favoritesManager.add(item.source, item.translation);
favButton.innerHTML = '✓';
setTimeout(() => { favButton.innerHTML = '⭐'; }, 1000);
});
historyItem.appendChild(favButton);
content.appendChild(historyItem);
}
}
},
favoritesPanel: {
element: null,
visible: false,
create: () => {
if (!components.favoritesPanel.element) {
// Create panel
const panel = document.createElement('div');
panel.className = 'translator-favorites-panel';
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 500px;
max-width: 90%;
max-height: 90vh;
background: white;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0,0,0,0.3);
z-index: 10000;
display: none;
flex-direction: column;
font-family: Arial, sans-serif;
overflow: hidden;
`;
// Header
const header = document.createElement('div');
header.style.cssText = `
padding: 15px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
`;
const title = document.createElement('h3');
title.textContent = '收藏夹';
title.style.margin = '0';
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✖';
closeBtn.style.cssText = 'background: none; border: none; font-size: 16px; cursor: pointer;';
closeBtn.addEventListener('click', () => components.favoritesPanel.hide());
header.appendChild(title);
header.appendChild(closeBtn);
panel.appendChild(header);
// Content
const content = document.createElement('div');
content.className = 'favorite-items';
content.style.cssText = 'flex: 1; overflow-y: auto; padding: 0 15px; max-height: 70vh;';
panel.appendChild(content);
// Footer
const footer = document.createElement('div');
footer.style.cssText = 'padding: 10px 15px; border-top: 1px solid #eee; text-align: right;';
const clearBtn = document.createElement('button');
clearBtn.textContent = '清空收藏';
clearBtn.style.cssText = 'padding: 8px 16px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;';
clearBtn.addEventListener('click', () => {
if (confirm('确定要清空所有收藏吗?')) {
Core.favoritesManager.clear();
components.favoritesPanel.update();
}
});
footer.appendChild(clearBtn);
panel.appendChild(footer);
// Add to document
document.body.appendChild(panel);
components.favoritesPanel.element = panel;
}
return components.favoritesPanel.element;
},
show: () => {
const panel = components.favoritesPanel.create();
components.favoritesPanel.update();
panel.style.display = 'flex';
components.favoritesPanel.visible = true;
},
hide: () => {
if (components.favoritesPanel.element) {
components.favoritesPanel.element.style.display = 'none';
components.favoritesPanel.visible = false;
}
},
isVisible: () => components.favoritesPanel.visible,
update: () => {
const panel = components.favoritesPanel.element;
if (!panel) return;
const content = panel.querySelector('.favorite-items');
if (!content) return;
// Clear content
content.innerHTML = '';
// Get favorites
const favorites = State.get('translationFavorites');
if (favorites.length === 0) {
content.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无收藏</div>';
return;
}
// Create items in reverse order (newest first)
for (let i = favorites.length - 1; i >= 0; i--) {
const item = favorites[i];
const date = new Date(item.timestamp);
const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
const favoriteItem = document.createElement('div');
favoriteItem.className = 'favorite-item';
favoriteItem.style.cssText = 'padding: 15px 0; border-bottom: 1px solid #eee; position: relative;';
favoriteItem.innerHTML = `
<div style="color: #999; font-size: 12px; margin-bottom: 5px;">${dateStr}</div>
<div style="margin-bottom: 8px; font-weight: bold;">${item.source}</div>
<div>${item.translation}</div>
`;
// Remove from favorites button
const removeButton = document.createElement('button');
removeButton.innerHTML = '✖';
removeButton.title = '移除收藏';
removeButton.style.cssText = 'position: absolute; top: 15px; right: 0; background: none; border: none; font-size: 16px; cursor: pointer;';
removeButton.addEventListener('click', () => {
Core.favoritesManager.remove(item.source);
components.favoritesPanel.update();
});
favoriteItem.appendChild(removeButton);
content.appendChild(favoriteItem);
}
}
},
// Bottom page buttons
bottomButtons: {
element: null,
stateManager: null,
create: () => {
if (!components.bottomButtons.element) {
// Create container
const container = document.createElement('div');
container.className = 'translator-bottom-buttons';
container.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9995;
`;
// Settings button
const settingsBtn = document.createElement('button');
settingsBtn.innerHTML = '⚙️';
settingsBtn.title = '设置';
settingsBtn.style.cssText = `
width: 50px;
height: 50px;
border-radius: 50%;
background-color: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border: none;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
`;
settingsBtn.addEventListener('click', () => {
UI.components.settingsPanel.show();
});
// History button
const historyBtn = document.createElement('button');
historyBtn.innerHTML = '📜';
historyBtn.title = '翻译历史';
historyBtn.style.cssText = `
width: 50px;
height: 50px;
border-radius: 50%;
background-color: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border: none;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
`;
historyBtn.addEventListener('click', () => {
UI.components.historyPanel.show();
});
// Favorites button
const favoritesBtn = document.createElement('button');
favoritesBtn.innerHTML = '⭐';
favoritesBtn.title = '收藏夹';
favoritesBtn.style.cssText = `
width: 50px;
height: 50px;
border-radius: 50%;
background-color: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border: none;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
`;
favoritesBtn.addEventListener('click', () => {
UI.components.favoritesPanel.show();
});
// Translate page button
const translatePageBtn = document.createElement('button');
translatePageBtn.innerHTML = '🌐';
translatePageBtn.title = '翻译整页 (长按重新翻译)';
translatePageBtn.style.cssText = `
width: 50px;
height: 50px;
border-radius: 50%;
background-color: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border: none;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
`;
// Track press duration for the long press
let pressTimer;
let isLongPress = false;
translatePageBtn.addEventListener('mousedown', () => {
isLongPress = false;
pressTimer = setTimeout(() => {
isLongPress = true;
// Visual feedback
translatePageBtn.style.backgroundColor = '#5cb85c';
translatePageBtn.style.color = 'white';
}, 800); // Long press threshold: 800ms
});
translatePageBtn.addEventListener('mouseup', () => {
clearTimeout(pressTimer);
// Reset style if it was changed
if (isLongPress) {
translatePageBtn.style.backgroundColor = 'white';
translatePageBtn.style.color = 'inherit';
}
if (!State.get('isTranslatingFullPage')) {
if (isLongPress) {
// Long press - force re-translation
const segments = State.get('translationSegments');
const hasCachedTranslations = segments && segments.length > 0 && segments.some(s => s.fromCache);
if (hasCachedTranslations) {
if (confirm('确定要忽略缓存重新翻译整个页面吗?这可能需要更长时间。')) {
Core.translateFullPage({ forceRetranslate: true }).catch(error => {
alert(`翻译整页失败: ${error.message}`);
});
}
} else {
Core.translateFullPage({ forceRetranslate: true }).catch(error => {
alert(`翻译整页失败: ${error.message}`);
});
}
} else {
// Normal click - regular translation
Core.translateFullPage().catch(error => {
alert(`翻译整页失败: ${error.message}`);
});
}
}
});
// Cancel long press if mouse leaves the button
translatePageBtn.addEventListener('mouseout', () => {
clearTimeout(pressTimer);
// Reset style if needed
if (isLongPress) {
translatePageBtn.style.backgroundColor = 'white';
translatePageBtn.style.color = 'inherit';
isLongPress = false;
}
});
// Add buttons to container
container.appendChild(translatePageBtn);
container.appendChild(historyBtn);
container.appendChild(favoritesBtn);
container.appendChild(settingsBtn);
// Store element references
components.bottomButtons.element = container;
components.bottomButtons.translateButton = translatePageBtn;
// Add to document
document.body.appendChild(container);
}
return components.bottomButtons.element;
},
setupStateSubscriptions: () => {
// Clean up existing subscriptions
if (components.bottomButtons.stateManager) {
components.bottomButtons.stateManager.cleanup();
}
// Create state manager for this component
const stateManager = State.registerComponent('bottomButtons');
components.bottomButtons.stateManager = stateManager;
// Subscribe to translation state
stateManager.subscribe('isTranslatingFullPage', isTranslating => {
const translateBtn = components.bottomButtons.translateButton;
if (translateBtn) {
translateBtn.disabled = isTranslating;
translateBtn.style.opacity = isTranslating ? '0.5' : '1';
translateBtn.style.cursor = isTranslating ? 'not-allowed' : 'pointer';
}
});
},
show: () => {
const buttons = components.bottomButtons.create();
buttons.style.display = 'flex';
// Set up state subscriptions
components.bottomButtons.setupStateSubscriptions();
},
hide: () => {
if (components.bottomButtons.element) {
components.bottomButtons.element.style.display = 'none';
// Clean up subscriptions
if (components.bottomButtons.stateManager) {
components.bottomButtons.stateManager.cleanup();
}
}
}
}
};
// Initialize UI
const init = () => {
// Setup selection event listeners
document.addEventListener('mouseup', (e) => {
// Don't show translate button if clicked in a popup
if (e.target.closest('.translation-popup')) {
return;
}
// Get selected text
const selection = window.getSelection();
const text = selection.toString().trim();
// Hide translate button if no text is selected
if (text.length === 0) {
components.translateButton.hide();
return;
}
// Get selection rectangle
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// Update state
State.set('lastSelectedText', text);
State.set('lastSelectionRect', rect);
// Show translate button
components.translateButton.show(rect);
});
// Hide translate button when clicking outside
document.addEventListener('mousedown', (e) => {
// Don't hide if clicked on translate button or popup
if (e.target.closest('.translate-button') || e.target.closest('.translation-popup')) {
return;
}
components.translateButton.hide();
});
// Show bottom buttons
components.bottomButtons.show();
};
return {
init,
components
};
})();
/**
* Utils Module - Utility functions
*/
const Utils = (function() {
// Language detection
const detectLanguage = (text) => {
return new Promise((resolve) => {
const chineseRegex = /[\u4e00-\u9fa5]/;
const englishRegex = /[a-zA-Z]/;
const japaneseRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
const koreanRegex = /[\uAC00-\uD7AF\u1100-\u11FF]/;
if (chineseRegex.test(text)) resolve('中文');
else if (englishRegex.test(text)) resolve('英语');
else if (japaneseRegex.test(text)) resolve('日语');
else if (koreanRegex.test(text)) resolve('韩语');
else resolve('未知');
});
};
// HTML utilities
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const decodeHtmlEntities = (text) => {
const div = document.createElement('div');
div.innerHTML = text;
return div.textContent;
};
// Text processing utilities
const isShortEnglishPhrase = (text) => {
// Check if the text is a short English phrase (for word explanation mode)
const trimmedText = text.trim();
const words = trimmedText.split(/\s+/);
// Short phrase has at most 5 words and is less than 30 characters
return (
/^[a-zA-Z\s.,;:'"-?!()]+$/.test(trimmedText) &&
words.length <= 5 &&
trimmedText.length < 30
);
};
// Text node extraction for page content
const extractTextNodesFromElement = (element, textSegments = [], depth = 0, excludeSelectors = null) => {
// Skip if element is null or invalid
if (!element) return textSegments;
// Skip excluded elements
if (excludeSelectors && element.matches && element.matches(excludeSelectors)) {
return textSegments;
}
try {
// For element nodes
if (element.nodeType === Node.ELEMENT_NODE) {
// Skip hidden elements and non-content elements
try {
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return textSegments;
}
} catch (e) {
// Ignore style errors
}
// Skip script, style, and other non-content elements
if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(element.tagName)) {
return textSegments;
}
// Special handling for headings to keep their structure
if (/^H[1-6]$/.test(element.tagName) && element.textContent.trim()) {
textSegments.push({
node: element,
text: element.textContent.trim(),
depth: depth,
isHeading: true,
element: element
});
return textSegments;
}
// Process paragraphs and blocks that contain simple content
// Check if this is a simple text block with basic formatting
if (['P', 'DIV', 'LI', 'TD', 'SPAN'].includes(element.tagName) &&
element.textContent.trim() &&
!element.querySelector('div, p, section, article, h1, h2, h3, h4, h5, h6, ul, ol, table, img, figure')) {
textSegments.push({
node: element,
text: element.textContent.trim(),
depth: depth,
isFormattedElement: true,
element: element
});
return textSegments;
}
// Process img elements - skip img elements completely
if (element.tagName === 'IMG') {
return textSegments;
}
// Recursively process all child nodes
for (let i = 0; i < element.childNodes.length; i++) {
const child = element.childNodes[i];
extractTextNodesFromElement(child, textSegments, depth + 1, excludeSelectors);
}
}
// Process text nodes
else if (element.nodeType === Node.TEXT_NODE) {
const text = element.textContent.trim();
if (text) {
textSegments.push({
node: element,
text: text,
depth: depth,
parent: element.parentElement
});
}
}
} catch (error) {
console.warn("Error processing element:", error);
}
return textSegments;
};
// Merge text segments into manageable chunks
const mergeTextSegments = (textSegments, maxLength = 2000) => {
if (!textSegments || textSegments.length === 0) {
return [];
}
const merged = [];
let currentSegment = {
nodes: [],
text: '',
translation: null,
error: null
};
// Sort segments by document position when possible
textSegments.sort((a, b) => {
// If both are regular nodes, compare document position
if (a.node && b.node && !a.isHeading && !b.isHeading && !a.isFormattedElement && !b.isFormattedElement) {
return a.node.compareDocumentPosition(b.node) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
}
// Special handling for headings and formatted elements - preserve their order
return 0;
});
for (const segment of textSegments) {
// Start a new segment for headings and formatted elements
if (segment.isHeading || segment.isFormattedElement ||
(currentSegment.text.length + segment.text.length > maxLength && currentSegment.nodes.length > 0)) {
if (currentSegment.nodes.length > 0) {
merged.push(currentSegment);
}
// Create a dedicated segment for headings and formatted elements
if (segment.isHeading || segment.isFormattedElement) {
merged.push({
nodes: [segment],
text: segment.text,
translation: null,
error: null,
isHeading: segment.isHeading,
isFormattedElement: segment.isFormattedElement
});
// Start fresh for next segment
currentSegment = {
nodes: [],
text: '',
translation: null,
error: null
};
continue;
} else {
// Regular new segment
currentSegment = {
nodes: [],
text: '',
translation: null,
error: null
};
}
}
// Add the segment to the current merged segment
currentSegment.nodes.push(segment);
// Add a space if the current segment is not empty
if (currentSegment.text.length > 0) {
currentSegment.text += ' ';
}
currentSegment.text += segment.text;
}
// Push the last segment if it's not empty
if (currentSegment.nodes.length > 0) {
merged.push(currentSegment);
}
return merged;
};
// Extract page content for translation
const extractPageContent = () => {
const selector = Config.getSetting('fullPageTranslationSelector');
const excludeSelectors = Config.getSetting('excludeSelectors');
const maxLength = Config.getSetting('fullPageMaxSegmentLength');
let elements = [];
try {
elements = document.querySelectorAll(selector);
} catch (e) {
throw new Error(`选择器语法错误: ${e.message}`);
}
if (elements.length === 0) {
throw new Error(`未找到匹配选择器 "${selector}" 的元素`);
}
// Extract text nodes from all matching elements
let allTextNodes = [];
elements.forEach(element => {
const textNodes = extractTextNodesFromElement(element, [], 0, excludeSelectors);
allTextNodes = allTextNodes.concat(textNodes);
});
// If no text nodes found
if (allTextNodes.length === 0) {
throw new Error('未找到可翻译的文本内容');
}
// Merge text nodes into segments
return mergeTextSegments(allTextNodes, maxLength);
};
// Detect main content of the page
const detectMainContent = () => {
// Try to find the main content area of the page
const possibleSelectors = [
'article', 'main', '.article', '.post', '.content', '#content',
'[role="main"]', '.main-content', '#main-content', '.post-content',
'.entry-content', '.article-content', '.story', '.body'
];
// Check if any of the selectors exist on the page
for (const selector of possibleSelectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
// Find the element with the most text content
let bestElement = null;
let maxTextLength = 0;
elements.forEach(element => {
const textLength = element.textContent.trim().length;
if (textLength > maxTextLength) {
maxTextLength = textLength;
bestElement = element;
}
});
if (bestElement && maxTextLength > 500) {
return bestElement;
}
}
}
// If no specific content area found, analyze paragraphs
const paragraphs = document.querySelectorAll('p');
if (paragraphs.length > 5) {
// Group nearby paragraphs to find content clusters
const clusters = [];
let currentCluster = null;
let lastRect = null;
paragraphs.forEach(p => {
const rect = p.getBoundingClientRect();
const text = p.textContent.trim();
// Skip empty paragraphs
if (text.length < 20) return;
// Start a new cluster if needed
if (!currentCluster || !lastRect || Math.abs(rect.top - lastRect.bottom) > 100) {
if (currentCluster) {
clusters.push(currentCluster);
}
currentCluster = {
elements: [p],
textLength: text.length
};
} else {
// Add to current cluster
currentCluster.elements.push(p);
currentCluster.textLength += text.length;
}
lastRect = rect;
});
// Add the last cluster
if (currentCluster) {
clusters.push(currentCluster);
}
// Find the cluster with the most text
let bestCluster = null;
let maxClusterTextLength = 0;
clusters.forEach(cluster => {
if (cluster.textLength > maxClusterTextLength) {
maxClusterTextLength = cluster.textLength;
bestCluster = cluster;
}
});
if (bestCluster && bestCluster.elements.length > 0) {
// Find common ancestor of elements in best cluster
const firstElement = bestCluster.elements[0];
let commonAncestor = firstElement.parentElement;
// Go up the DOM tree to find an ancestor that contains at least 80% of the cluster's elements
while (commonAncestor && commonAncestor !== document.body) {
let containedCount = 0;
bestCluster.elements.forEach(el => {
if (commonAncestor.contains(el)) {
containedCount++;
}
});
if (containedCount >= bestCluster.elements.length * 0.8) {
return commonAncestor;
}
commonAncestor = commonAncestor.parentElement;
}
// Fallback to the first paragraph's parent if no good common ancestor
return firstElement.parentElement;
}
}
// Default to body if nothing better found
return document.body;
};
return {
detectLanguage,
escapeHtml,
decodeHtmlEntities,
isShortEnglishPhrase,
extractTextNodesFromElement,
mergeTextSegments,
extractPageContent,
detectMainContent
};
})();
/**
* Core Module - Main application logic
*/
const Core = (function() {
// Private cache for tracking initialization
let isInitialized = false;
// Translation favorites and history management
const historyManager = {
add: (source, translation) => {
// Add to history
const history = State.get('translationHistory');
// Create history item
const item = {
source,
translation,
timestamp: Date.now()
};
// Add to the beginning
const newHistory = [item, ...history.filter(h => h.source !== source)];
// Limit history size
const maxHistorySize = Config.getSetting('historySize');
if (newHistory.length > maxHistorySize) {
newHistory.length = maxHistorySize;
}
// Update state
State.set('translationHistory', newHistory);
},
clear: () => {
State.set('translationHistory', []);
}
};
// Translation favorites management
const favoritesManager = {
add: (source, translation) => {
const favorites = State.get('translationFavorites');
// Create favorite item
const item = {
source,
translation,
timestamp: Date.now()
};
// Add to the beginning if not already exists
const newFavorites = [item, ...favorites.filter(f => f.source !== source)];
// Update state
State.set('translationFavorites', newFavorites);
},
remove: (source) => {
const favorites = State.get('translationFavorites');
// Filter out the item
const newFavorites = favorites.filter(f => f.source !== source);
// Update state
State.set('translationFavorites', newFavorites);
},
clear: () => {
State.set('translationFavorites', []);
},
isFavorite: (source) => {
const favorites = State.get('translationFavorites');
return favorites.some(f => f.source === source);
}
};
// Translation cache management
const cacheManager = {
add: (source, translation) => {
// 修改缓存策略:对于短文本(小于3字符)的特殊处理
// 1) 如果文本太长,仍然不缓存
if (source.length > 10000) return;
// 2) 对于极短文本,我们缓存它但添加特殊标记
const isShortText = source.length < 3;
State.debugLog('Adding to cache:', source, translation, isShortText ? '(短文本)' : '');
// Get existing cache
const cache = State.get('translationCache');
// Add to cache with timestamp
cache[source] = {
translation,
timestamp: Date.now(),
isShortText // 标记是否为短文本
};
// Prune cache if it's too large
cacheManager.prune();
// Update state
State.set('translationCache', cache);
},
get: (source) => {
const cache = State.get('translationCache');
return cache[source] ? cache[source].translation : null;
},
clear: () => {
State.set('translationCache', {});
},
prune: () => {
const cache = State.get('translationCache');
const maxCacheSize = Config.getSetting('maxCacheSize');
const maxCacheAge = Config.getSetting('maxCacheAge') * 24 * 60 * 60 * 1000; // Convert days to milliseconds
// If cache is not too large, just return
if (Object.keys(cache).length <= maxCacheSize) return;
// Get all entries with timestamps
const entries = Object.entries(cache).map(([source, data]) => ({
source,
timestamp: data.timestamp || 0
}));
// Remove old entries beyond max age
const now = Date.now();
const recentEntries = entries.filter(e => now - e.timestamp <= maxCacheAge);
// If we're still over the limit, remove least recently used
if (recentEntries.length > maxCacheSize) {
// Sort by timestamp (oldest first)
recentEntries.sort((a, b) => a.timestamp - b.timestamp);
// Keep only the newest entries
recentEntries.length = maxCacheSize;
}
// Create new cache with only the entries we want to keep
const newCache = {};
recentEntries.forEach(e => {
newCache[e.source] = cache[e.source];
});
// Update state
State.set('translationCache', newCache);
},
// Apply cached translations to current segments
apply: async () => {
const segments = State.get('translationSegments');
if (!segments || segments.length === 0) return false;
// Set cache application state
State.set('isApplyingCache', true);
State.set('isStopped', false);
State.set('isTranslatingFullPage', true); // Ensure we're in translating state
let appliedCount = 0;
// Try to apply cached translations
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const cachedTranslation = cacheManager.get(segment.text);
if (cachedTranslation) {
segment.translation = cachedTranslation;
segment.fromCache = true;
appliedCount++;
// Apply translation to DOM immediately for this segment
applyTranslationToSegment(segment);
// 更新进度,在每个缓存段落应用后触发进度更新
State.set('lastTranslatedIndex', i);
}
}
// Done applying cache - set state to finished
State.set('isApplyingCache', false);
State.set('cacheApplied', appliedCount > 0);
// 完成缓存应用后,再次更新进度以确保UI正确反映当前状态
if (appliedCount > 0) {
State.set('lastTranslatedIndex', segments.findIndex(s => !s.translation && !s.error));
// If we applied all segments from cache, mark as complete
const allSegmentsTranslated = segments.every(s => s.translation || s.error);
if (allSegmentsTranslated) {
// All segments are translated from cache, stop the translation process
State.set('isTranslatingFullPage', false);
// Ensure UI shows complete status
const statusElement = UI.components.pageControls.statusElement;
if (statusElement) {
statusElement.textContent = '翻译完成 (全部来自缓存)';
statusElement.style.color = '#4CAF50';
}
}
}
return appliedCount > 0;
}
};
// Initialize the application
const init = () => {
if (isInitialized) return;
// Initialize all modules
Config.init();
UI.init();
// Register menu commands
GM_registerMenuCommand('翻译设置', () => {
UI.components.settingsPanel.show();
});
GM_registerMenuCommand('翻译历史', () => {
UI.components.historyPanel.show();
});
GM_registerMenuCommand('翻译收藏夹', () => {
UI.components.favoritesPanel.show();
});
GM_registerMenuCommand('翻译整页', () => {
Core.translateFullPage().catch(error => {
alert(`翻译整页失败: ${error.message}`);
});
});
// Set up global state change handlers
State.subscribe('translationHistory', () => {
if (UI.components.historyPanel) {
UI.components.historyPanel.update();
}
});
State.subscribe('translationFavorites', () => {
if (UI.components.favoritesPanel) {
UI.components.favoritesPanel.update();
}
});
isInitialized = true;
State.debugLog('Translator initialized');
};
// Translation functionality
const translateSelectedText = async (text, rect, isExplanationMode = false) => {
if (!text || text.trim().length === 0) return;
try {
// Get translation context if enabled
let context = null;
if (Config.getSetting('useTranslationContext')) {
const history = State.get('translationHistory');
const contextSize = Config.getSetting('contextSize');
context = history.slice(-contextSize).map(item => ({
source: item.source,
translation: item.translation
}));
}
// Perform translation
const translation = await API.retryTranslation(text, {
isWordExplanationMode: isExplanationMode,
context
});
// Add to history
historyManager.add(text, translation);
// Add to cache
cacheManager.add(text, translation);
return translation;
} catch (error) {
State.debugLog('Translation error:', error);
throw error;
}
};
const translateFullPage = async (options = {}) => {
// Default options
const defaultOptions = {
forceRetranslate: false, // Whether to force re-translation even when cache is available
};
const opts = {...defaultOptions, ...options};
// If translation is already in progress, don't start a new one
if (State.get('isTranslatingFullPage')) {
return;
}
// If we have previously translated segments, check whether to restart
const existingSegments = State.get('translationSegments');
if (existingSegments && existingSegments.length > 0) {
// We're restarting a translation - reset everything
restoreOriginalText(true);
}
try {
// Set translation state
State.set('isTranslatingFullPage', true);
State.set('isTranslationPaused', false);
State.set('isStopped', false);
State.set('lastTranslatedIndex', -1);
State.set('isShowingTranslation', true);
// Extract content for translation
let segments;
if (Config.getSetting('detectArticleContent')) {
// Detect main content area
const mainContent = Utils.detectMainContent();
// Override selector temporarily to target the main content
const originalSelector = Config.getSetting('fullPageTranslationSelector');
// Create a unique selector for the detected element
let tempId = 'translator-detected-content-' + Date.now();
mainContent.id = tempId;
Config.updateSetting('fullPageTranslationSelector', '#' + tempId);
segments = Utils.extractPageContent();
// Restore original selector
Config.updateSetting('fullPageTranslationSelector', originalSelector);
// Remove temporary ID
mainContent.removeAttribute('id');
} else {
// Use configured selector
segments = Utils.extractPageContent();
}
// Store segments and original texts
State.set('translationSegments', segments);
State.set('originalTexts', segments.map(s => s.text));
// Show translation controls
UI.components.pageControls.show();
// Check if we should apply cache or force re-translation
if (!opts.forceRetranslate) {
// Attempt to apply translations from cache
const cacheApplied = await cacheManager.apply();
// Start translating uncached segments if needed
if (cacheApplied) {
// Start translating from where cache left off
const untranslatedIndex = segments.findIndex(s => !s.translation && !s.error);
if (untranslatedIndex !== -1) {
await translateNextSegment(untranslatedIndex);
}
} else {
// No cache applied, start from beginning
await translateNextSegment(0);
}
} else {
// Force re-translation - ignore cache and start from beginning
segments.forEach(segment => {
// Clear previous translations but keep the text
segment.translation = null;
segment.error = null;
segment.fromCache = false;
segment.pending = false;
});
// Start translating from beginning
await translateNextSegment(0);
}
return true;
} catch (error) {
State.set('isTranslatingFullPage', false);
State.debugLog('Full page translation error:', error);
throw error;
}
};
// Translate the next segment in a full page translation
const translateNextSegment = async (index) => {
const segments = State.get('translationSegments');
// Check if index is valid
if (index < 0 || index >= segments.length) {
// 处理无效索引的情况,通常意味着已经翻译完成所有段落
// 更新最后翻译的索引为最大值,确保进度为100%
State.set('lastTranslatedIndex', segments.length - 1);
State.set('isTranslatingFullPage', false);
// 更新状态为完成
const statusElement = UI.components.pageControls.statusElement;
if (statusElement) {
statusElement.textContent = '翻译完成';
statusElement.style.color = '#4CAF50';
}
// 手动强制更新进度显示为100%
const { indicator, percentage } = UI.components.pageControls.progressElement;
indicator.style.width = '100%';
percentage.textContent = `100% (${segments.length}/${segments.length})`;
// Final stats update
UI.components.pageControls.updateStats(segments);
return;
}
// Check if translation is paused or stopped
if (State.get('isTranslationPaused') || State.get('isStopped')) {
return;
}
try {
const segment = segments[index];
// Skip already translated segments
if (segment.translation || segment.error) {
// Continue with next segment
if (index < segments.length - 1) {
translateNextSegment(index + 1);
} else {
// Translation complete
// 确保最后一个段落也被计入进度
State.set('lastTranslatedIndex', segments.length - 1);
State.set('isTranslatingFullPage', false);
// Update status when translation is actually complete
const statusElement = UI.components.pageControls.statusElement;
if (statusElement) {
statusElement.textContent = '翻译完成';
statusElement.style.color = '#4CAF50';
}
// 手动强制更新进度显示为100%
const { indicator, percentage } = UI.components.pageControls.progressElement;
indicator.style.width = '100%';
percentage.textContent = `100% (${segments.length}/${segments.length})`;
// Final stats update
UI.components.pageControls.updateStats(segments);
}
return;
}
// Update progress
State.set('lastTranslatedIndex', index);
// Get context from previous segments if enabled
let context = null;
if (Config.getSetting('useTranslationContext')) {
const contextSize = Config.getSetting('contextSize');
context = [];
// Get context from previous segments
for (let i = Math.max(0, index - contextSize); i < index; i++) {
if (segments[i].translation) {
context.push({
source: segments[i].text,
translation: segments[i].translation
});
}
}
}
// Translate segment
const translation = await API.retryTranslation(segment.text, { context });
// Update segment with translation
segment.translation = translation;
// Explicitly mark as not pending
segment.pending = false;
// Add or update cache
if (segment.fromCache) {
// This was previously from cache but now we have a new translation
segment.fromCache = false;
}
cacheManager.add(segment.text, translation);
// Apply translations to DOM if we're showing them
if (State.get('isShowingTranslation')) {
applyTranslationToSegment(segment);
}
// Continue with next segment
if (index < segments.length - 1) {
translateNextSegment(index + 1);
} else {
// Translation complete
// 确保最后一个段落也被计入进度
State.set('lastTranslatedIndex', segments.length - 1);
State.set('isTranslatingFullPage', false);
// Update status when translation is actually complete
const statusElement = UI.components.pageControls.statusElement;
if (statusElement) {
statusElement.textContent = '翻译完成';
statusElement.style.color = '#4CAF50';
}
// 手动强制更新进度显示为100%
const { indicator, percentage } = UI.components.pageControls.progressElement;
indicator.style.width = '100%';
percentage.textContent = `100% (${segments.length}/${segments.length})`;
// Final stats update
UI.components.pageControls.updateStats(segments);
}
} catch (error) {
// Mark segment as having an error
segments[index].error = error.message;
// Explicitly mark as not pending
segments[index].pending = false;
// Continue with next segment
if (index < segments.length - 1) {
translateNextSegment(index + 1);
} else {
// Translation complete even with errors
// 确保最后一个段落也被计入进度计算
State.set('lastTranslatedIndex', segments.length - 1);
State.set('isTranslatingFullPage', false);
// Update status when translation is actually complete
const statusElement = UI.components.pageControls.statusElement;
if (statusElement) {
statusElement.textContent = '翻译完成';
statusElement.style.color = '#4CAF50';
}
// 手动强制更新进度显示为100%
const { indicator, percentage } = UI.components.pageControls.progressElement;
indicator.style.width = '100%';
percentage.textContent = `100% (${segments.length}/${segments.length})`;
// Final stats update
UI.components.pageControls.updateStats(segments);
}
}
};
// Stop translation but preserve the progress
const stopTranslation = () => {
State.set('isStopped', true);
State.set('isTranslatingFullPage', false);
// Reset pause state when translation is stopped
if (State.get('isTranslationPaused')) {
State.set('isTranslationPaused', false);
}
// Update status
const statusElement = UI.components.pageControls.statusElement;
if (statusElement) {
statusElement.textContent = '翻译已停止';
statusElement.style.color = '';
}
// 确保已翻译部分的进度正确反映
const segments = State.get('translationSegments');
if (segments && segments.length > 0) {
// 设置最后翻译索引以触发进度更新
const lastTranslated = segments.reduce((max, segment, idx) =>
(segment.translation || segment.error) ? idx : max, -1);
if (lastTranslated >= 0) {
State.set('lastTranslatedIndex', lastTranslated);
}
// Final stats update
UI.components.pageControls.updateStats(segments);
}
};
// Apply translation to a segment
const applyTranslationToSegment = (segment) => {
if (!segment.translation) return;
if (segment.isHeading || segment.isFormattedElement) {
// For headings and formatted elements
const firstNode = segment.nodes[0];
const element = firstNode.element;
if (element) {
if (Config.getSetting('showSourceLanguage')) {
// Style the original
element.style.color = '#999';
element.style.fontStyle = 'italic';
element.style.marginBottom = '5px';
// Find or create translation element
let translationElement = element.nextSibling;
if (!translationElement || !translationElement.classList || !translationElement.classList.contains('translated-text')) {
// Create new element
translationElement = document.createElement('div');
translationElement.className = 'translated-text';
translationElement.style.cssText = 'color: #333; font-style: normal;';
// Clone the element to preserve its structure but with translated text
const clonedElement = element.cloneNode(true);
// Replace all text nodes in the clone with translated text
replaceTextInElement(clonedElement, segment.translation);
translationElement.innerHTML = clonedElement.innerHTML;
element.parentNode.insertBefore(translationElement, element.nextSibling);
} else {
// Show existing translation
translationElement.style.display = '';
}
} else {
// Store original HTML
if (!element.getAttribute('data-original-text')) {
element.setAttribute('data-original-text', element.innerHTML);
}
// Replace all text nodes in the element with translated text
// This preserves HTML structure including links
replaceTextInElement(element, segment.translation);
}
}
} else {
// Apply translation to each individual text node
segment.nodes.forEach(nodeInfo => {
const originalNode = nodeInfo.node;
// Create the translation span
const translationSpan = document.createElement('span');
translationSpan.className = 'translated-text';
translationSpan.style.cssText = 'color: #333; font-style: normal;';
translationSpan.textContent = segment.translation;
// Replace original text with translation
if (originalNode && originalNode.parentNode) {
if (Config.getSetting('showSourceLanguage')) {
// Create original text span
const originalSpan = document.createElement('span');
originalSpan.className = 'original-text';
originalSpan.style.cssText = 'color: #999; font-style: italic; margin-right: 5px;';
originalSpan.textContent = originalNode.textContent;
// Insert original and translation
originalNode.parentNode.insertBefore(translationSpan, originalNode);
originalNode.parentNode.insertBefore(originalSpan, translationSpan);
} else {
// Just insert translation
originalNode.parentNode.insertBefore(translationSpan, originalNode);
}
// Hide original node
if (originalNode.style) {
originalNode.style.display = 'none';
}
}
});
}
};
// Helper function to replace text in an element while preserving structure
const replaceTextInElement = (element, translation) => {
const textNodes = [];
// Extract all text nodes from the element
const extractTextNodes = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent.trim()) {
textNodes.push(node);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(extractTextNodes);
}
};
extractTextNodes(element);
// If there's only one text node, directly replace it
if (textNodes.length === 1) {
textNodes[0].textContent = translation;
return;
}
// For multiple text nodes, distribute translation proportionally
const totalOriginalLength = textNodes.reduce(
(sum, node) => sum + node.textContent.trim().length, 0);
if (totalOriginalLength > 0) {
let startPos = 0;
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i];
const nodeText = node.textContent.trim();
if (nodeText.length > 0) {
// Calculate ratio for this node
const ratio = nodeText.length / totalOriginalLength;
// Calculate text length for this node
const chunkLength = Math.round(translation.length * ratio);
// Extract portion of translation
let chunk;
if (i === textNodes.length - 1) {
// Last node gets remainder
chunk = translation.substring(startPos);
} else {
// Other nodes get proportional amount
chunk = translation.substring(startPos, startPos + chunkLength);
startPos += chunkLength;
}
// Update node text
node.textContent = chunk;
}
}
} else {
// Fallback: put all translation in first text node if found
if (textNodes.length > 0) {
textNodes[0].textContent = translation;
for (let i = 1; i < textNodes.length; i++) {
textNodes[i].textContent = '';
}
}
}
};
// Toggle to show translations (opposite of restoreOriginalText)
const showTranslation = (removeControls = false) => {
const segments = State.get('translationSegments');
if (!segments || segments.length === 0) {
return;
}
// Show each segment's translation
segments.forEach(segment => {
if (!segment.translation) return;
if (segment.isHeading || segment.isFormattedElement) {
// For headings and formatted elements
const firstNode = segment.nodes[0];
const element = firstNode.element;
if (element) {
const originalText = element.getAttribute('data-original-text');
if (Config.getSetting('showSourceLanguage')) {
// Style the original
element.style.color = '#999';
element.style.fontStyle = 'italic';
element.style.marginBottom = '5px';
// Find or create the translation element
let translationElement = element.nextSibling;
if (!translationElement || !translationElement.classList || !translationElement.classList.contains('translated-text')) {
// Translation element doesn't exist, create it
translationElement = document.createElement('div');
translationElement.className = 'translated-text';
translationElement.style.cssText = 'color: #333; font-style: normal;';
// Clone the element to preserve its structure but with translated text
const clonedElement = element.cloneNode(true);
// Replace all text nodes in the clone with translated text
replaceTextInElement(clonedElement, segment.translation);
translationElement.innerHTML = clonedElement.innerHTML;
element.parentNode.insertBefore(translationElement, element.nextSibling);
} else {
// Show existing translation
translationElement.style.display = '';
}
} else {
// Replace content even if originalText is not set yet
if (!originalText) {
// Store original content if not already stored
element.setAttribute('data-original-text', element.innerHTML);
}
// Replace all text nodes in the element with translated text
// This preserves HTML structure including links
replaceTextInElement(element, segment.translation);
}
}
} else {
// For regular text nodes
if (!segment.nodes) return;
segment.nodes.forEach(nodeInfo => {
if (!nodeInfo) return;
const originalNode = nodeInfo.node;
if (originalNode && originalNode.parentNode) {
// Show original node
if (originalNode.style) {
originalNode.style.display = '';
}
// Remove or hide translation elements
let sibling = originalNode.previousSibling;
while (sibling) {
const prevSibling = sibling.previousSibling;
if (sibling.classList &&
(sibling.classList.contains('translated-text') ||
sibling.classList.contains('original-text'))) {
if (removeControls && sibling.parentNode) {
sibling.parentNode.removeChild(sibling);
} else if (sibling.style) {
sibling.style.display = 'none';
}
}
sibling = prevSibling;
}
}
});
}
});
};
// Restore original text for a full page translation
const restoreOriginalText = (removeControls = false) => {
const segments = State.get('translationSegments');
if (!segments || segments.length === 0) {
return;
}
// Restore each segment
segments.forEach(segment => {
if (segment.isHeading || segment.isFormattedElement) {
// For headings and formatted elements
const firstNode = segment.nodes[0];
const element = firstNode.element;
if (element) {
// Restore original style
if (element.style) {
element.style.color = '';
element.style.fontStyle = '';
element.style.marginBottom = '';
}
// Restore original content if replaced
const originalText = element.getAttribute('data-original-text');
if (originalText) {
element.innerHTML = originalText;
element.removeAttribute('data-original-text');
}
// Hide translation element if it was added separately
if (Config.getSetting('showSourceLanguage')) {
const nextSibling = element.nextSibling;
if (nextSibling && nextSibling.className === 'translated-text' && nextSibling.style) {
nextSibling.style.display = 'none';
}
}
}
} else {
// For regular text nodes
if (!segment.nodes) return;
segment.nodes.forEach(nodeInfo => {
if (!nodeInfo) return;
const originalNode = nodeInfo.node;
if (originalNode && originalNode.parentNode) {
// Show original node
if (originalNode.style) {
originalNode.style.display = '';
}
// Remove or hide translation elements
let sibling = originalNode.previousSibling;
while (sibling) {
const prevSibling = sibling.previousSibling;
if (sibling.classList &&
(sibling.classList.contains('translated-text') ||
sibling.classList.contains('original-text'))) {
if (removeControls && sibling.parentNode) {
sibling.parentNode.removeChild(sibling);
} else if (sibling.style) {
sibling.style.display = 'none';
}
}
sibling = prevSibling;
}
}
});
}
});
// Update state
State.set('isShowingTranslation', false);
// Remove page controls if requested
if (removeControls) {
UI.components.pageControls.hide();
}
};
return {
init,
translateSelectedText,
translateFullPage,
translateNextSegment,
stopTranslation,
showTranslation,
restoreOriginalText,
applyTranslationToSegment,
historyManager,
favoritesManager,
cacheManager
};
})();
// Initialize the application
Core.init();
})();