// ==UserScript==
// @name Linux Do Translate
// @namespace linux-do-translate
// @version 0.2.4
// @author delph1s
// @license MIT
// @description 对回复进行翻译
// @match https://linux.do/t/topic/*
// @connect *
// @icon https://cdn.linux.do/uploads/default/original/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994.png
// @grant unsafeWindow
// @grant window.close
// @grant window.focus
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const REQUIRED_CHARS = 6;
const SPACE_PRESS_COUNT = 3; // 连按次数
const SPACE_PRESS_TIMEOUT = 1500; // 连续按键的最大时间间隔(毫秒)
const TRANSLATE_PROVIDERS = [
{
text: 'LinuxDo Deeplx',
value: 'deeplx-linuxdo',
},
{
text: 'Deeplx',
value: 'deeplx',
},
{
text: 'Deepl',
value: 'deepl',
},
{
text: 'OpenAI',
value: 'oai',
},
{
text: 'OpenAI Proxy',
value: 'oai-proxy',
},
];
const NOT_CUSTOM_URL_PROVIDERS = ['oai', 'deepl', 'deeplx-linuxdo'];
const TRANSLATE_TARGET_LANG = {
EN: { oai: 'English', deepl: 'EN' },
ZH: { oai: 'Chinese', deepl: 'ZH' },
};
const TRANSLATE_TARGET_LANG_OPTIONS = [
{
text: 'English(英文)',
value: 'EN',
},
{
text: '中文(Chinese)',
value: 'ZH',
},
];
const DEFAULT_CONFIG = {
maxRetryTimes: 5,
customUrl: '',
authKey: '',
enableTranslate: false,
translateSourceLang: 'ZH',
translateTargetLang: 'EN',
translateProvider: 'deeplx-linuxdo',
translateModel: 'gpt-4o',
translateLayout: 'top',
translateSize: 80,
translateItalics: true,
translateBold: false,
translateReference: false,
closeConfigAfterSave: true,
};
const uiIDs = {
replyControl: 'reply-control',
configButton: 'multi-lang-say-config-button',
configPanel: 'multi-lang-say-config-panel',
customUrlInput: 'custom-url-input',
authKeyInput: 'auth-key-input',
enableTranslateSwitch: 'enable-translate-switch',
translateSourceLangSelect: 'translate-source-lang-select',
translateTargetLangSelect: 'translate-target-lang-select',
translateProviderSelect: 'translate-provider-select',
translateModelInput: 'translate-model-input',
translateLayoutSelect: 'translate-layout-select',
translateSizeInput: 'translate-size-input',
translateItalicsSwitch: 'translate-italics-switch',
translateBoldSwitch: 'translate-bold-switch',
translateReferenceSwitch: 'translate-reference-switch',
closeConfigAfterSaveSwitch: 'close-after-save-switch',
};
let config = {
maxRetryTimes: GM_getValue('maxRetryTimes', DEFAULT_CONFIG.maxRetryTimes),
customUrl: GM_getValue('customUrl', DEFAULT_CONFIG.customUrl),
authKey: GM_getValue('authKey', DEFAULT_CONFIG.authKey),
enableTranslate: GM_getValue('enableTranslate', DEFAULT_CONFIG.enableTranslate),
translateSourceLang: GM_getValue('translateSourceLang', DEFAULT_CONFIG.translateSourceLang),
translateTargetLang: GM_getValue('translateTargetLang', DEFAULT_CONFIG.translateTargetLang),
translateProvider: GM_getValue('translateProvider', DEFAULT_CONFIG.translateProvider),
translateModel: GM_getValue('translateModel', DEFAULT_CONFIG.translateModel),
translateLayout: GM_getValue('translateLayout', DEFAULT_CONFIG.translateLayout),
translateSize: GM_getValue('translateSize', DEFAULT_CONFIG.translateSize),
translateItalics: GM_getValue('translateItalics', DEFAULT_CONFIG.translateItalics),
translateBold: GM_getValue('translateBold', DEFAULT_CONFIG.translateBold),
translateReference: GM_getValue('translateReference', DEFAULT_CONFIG.translateReference),
closeConfigAfterSave: GM_getValue('closeConfigAfterSave', DEFAULT_CONFIG.closeConfigAfterSave),
};
const genFormatDateTime = d => {
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
};
const genFormatNow = () => {
return genFormatDateTime(new Date());
};
/**
* 获取随机整数
*
* @param {number} start 范围开始
* @param {number} end 范围结束
* @returns
*/
const randInt = (start, end) => {
return Math.floor(Math.random() * (end - start + 1)) + start;
};
/**
* 随机睡眠(毫秒)
*
* @param {number} start 范围开始
* @param {number} end 范围结束
*/
const randSleep = async (start = 2000, end = 3000) => {
// 生成随机整数 randSleepTime,范围在 start 到 end 之间
const randSleepTime = getRandomInt(start, end);
// 睡眠时间
return await new Promise(resolve => setTimeout(resolve, randSleepTime));
};
/**
* 是否相同
*
* @param a
* @param b
* @returns
*/
const isEqual = (a, b) => {
if (a === null || a === undefined || b === null || b === undefined) {
return a === b;
}
if (typeof a !== typeof b) {
return false;
}
if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') {
return a === b;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}
return a.every((item, index) => isEqual(item, b[index]));
}
if (typeof a === 'object' && typeof b === 'object') {
const keysA = Object.keys(a || {});
const keysB = Object.keys(b || {});
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every(key => isEqual(a[key], b[key]));
}
return false;
};
/**
* 判断字符串中是否包含中文字符
* @param {string} text
* @returns {boolean}
*/
const containsChinese = text => {
return /[\u4e00-\u9fa5]/.test(text);
};
const getInvertColor = hex => {
// 去掉前面的“#”字符
hex = hex.replace('#', '');
// 如果输入的是3位的hex值,转换为6位的
if (hex.length === 3) {
hex = hex
.split('')
.map(c => c + c)
.join('');
}
// 计算相反的颜色
const r = (255 - parseInt(hex.slice(0, 2), 16)).toString(16).padStart(2, '0');
const g = (255 - parseInt(hex.slice(2, 4), 16)).toString(16).padStart(2, '0');
const b = (255 - parseInt(hex.slice(4, 6), 16)).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
};
const deeplxReq = text => {
return {
url: config.authKey ? `${config.customUrl}?token=${config.authKey}` : config.customUrl,
headers: {
'Content-Type': 'application/json',
},
data: JSON.stringify({
text: text,
target_lang: TRANSLATE_TARGET_LANG[config.translateTargetLang].deepl,
source_lang: 'auto',
}),
responseType: 'json',
};
};
const deeplxLinuxdoReq = text => {
return {
url: `https://api.deeplx.org/${config.authKey}/translate`,
headers: {
'Content-Type': 'application/json',
},
data: JSON.stringify({
text: text,
target_lang: TRANSLATE_TARGET_LANG[config.translateTargetLang].deepl,
source_lang: 'auto',
}),
responseType: 'json',
};
};
const deeplReq = text => {
const authKey = config.authKey;
const params = new URLSearchParams();
params.append('text', text);
params.append('target_lang', TRANSLATE_TARGET_LANG[config.translateTargetLang].deepl);
params.append('source_lang', TRANSLATE_TARGET_LANG[config.translateSourceLang].deepl);
return {
url: 'https://api.deepl.com/v2/translate', // DeepL Pro API
headers: {
Authorization: `DeepL-Auth-Key ${authKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
data: params.toString(),
responseType: 'json',
};
};
const deeplRes = res => {
return res?.translations?.[0]?.text;
};
const oaiReq = (
text,
model = 'gpt-3.5-turbo',
url = 'https://api.openai.com/v1/chat/completions',
temperature = 0.5,
maxTokens = 32000
) => {
const authKey = config.authKey;
return {
url: url,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authKey}`,
},
data: JSON.stringify({
model: model, // 或者您订阅的其他模型,例如 'gpt-4'
messages: [
{
role: 'system',
content:
'You are a highly skilled translation engine. Your function is to translate texts accurately into the target {{to}}, maintaining the original format, technical terms, and abbreviations. Do not add any explanations or annotations to the translated text.',
},
{
role: 'user',
content: `Translate the following source text to ${
TRANSLATE_TARGET_LANG[config.translateTargetLang].oai
}, Output translation directly without any additional text.\nSource Text: ${text}\nTranslated Text:`,
},
],
temperature: temperature, // 控制生成内容的随机性,范围是 0 到 1
max_tokens: maxTokens, // 响应的最大标记数
}),
responseType: 'json',
};
};
const oaiRes = res => {
return res.choices[0].message.content.trim();
};
const translateText = text => {
const isDeepl = config.translateProvider === 'deepl';
const isOAI = config.translateProvider === 'oai' || config.translateProvider === 'oai-proxy';
let reqData;
if (!config.authKey) {
if (!config.customUrl) return '';
if (config.translateProvider === 'deeplx') {
reqData = deeplxReq(text);
} else {
return '';
}
} else if (isDeepl) {
reqData = deeplReq(text);
} else if (isOAI) {
reqData = oaiReq(
text,
config.translateModel,
NOT_CUSTOM_URL_PROVIDERS.includes(config.translateProvider)
? 'https://api.openai.com/v1/chat/completions'
: config.customUrl,
0.5,
1600
);
} else {
reqData = deeplxLinuxdoReq(text);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: reqData.url,
headers: reqData.headers,
data: reqData.data,
responseType: reqData.responseType,
onload: function (res) {
console.log('Translation response:', res);
console.log('Request details:', reqData);
if (res.status === 200) {
try {
const response = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
console.log('Parsed response:', response);
let translation;
if (isDeepl) {
// Pro API 返回格式
translation = deeplRes(response);
console.log('DeepL translation:', translation);
} else if (isOAI) {
translation = oaiRes(response);
console.log('OAI translation:', translation);
} else {
translation = response?.data;
console.log('DeepLX translation:', translation);
}
resolve(translation || '');
} catch (error) {
console.error('Error parsing response:', error);
resolve('');
}
} else {
console.error('Translation failed:', {
status: res.status,
statusText: res.statusText,
response: res.response,
responseText: res.responseText,
finalUrl: res.finalUrl,
headers: res.responseHeaders,
});
resolve('');
}
},
onerror: function (err) {
console.error('Translation error details:', {
error: err,
errorText: err.toString(),
status: err.status,
statusText: err.statusText,
responseText: err.responseText,
});
resolve('');
},
});
});
};
const processTranslateText = async text => {
// 定义需要保护的块的正则表达式
const protectedBlocks = [
// Markdown 代码块
{
regex: /```[\s\S]*?```/g,
type: 'code',
},
// BBCode 标签块 (处理嵌套标签)
{
regex: /\[(size|spoiler|center|color|grid).*?\][\s\S]*?\[\/\1\]/g,
type: 'bbcode',
},
// 已有的 ruby 标签
{
regex: /<ruby>[\s\S]*?<\/ruby>/g,
type: 'ruby',
},
// HTML 标签块
{
regex: /<[^>]+>[\s\S]*?<\/[^>]+>/g,
type: 'html',
},
// 图片标签
{
regex: /!\[image\]\(.*?\)/g,
type: 'image',
},
];
// 创建占位符映射
let placeholders = new Map();
let placeholderCounter = 0;
// 保护特殊块
let processedText = text;
for (const block of protectedBlocks) {
processedText = processedText.replace(block.regex, match => {
const placeholder = `__PROTECTED_${block.type}_${placeholderCounter++}__`;
placeholders.set(placeholder, match);
return placeholder;
});
}
// 处理剩余文本
const segments = processedText.split(/(\n)/);
let translatedSegments = [];
for (const segment of segments) {
if (!segment.trim() || segment === '\n') {
translatedSegments.push(segment);
continue;
}
// 检查是否是占位符
if (segment.startsWith('__PROTECTED_')) {
translatedSegments.push(placeholders.get(segment));
continue;
}
// 翻译普通文本
let segmentTranslate = await translateText(segment);
if (segmentTranslate === '') {
return segmentTranslate;
}
if (config.translateItalics) {
segmentTranslate = `[i]${segmentTranslate}[/i]`;
}
if (config.translateBold) {
segmentTranslate = `[b]${segmentTranslate}[/b]`;
}
if (config.translateReference) {
segmentTranslate = `> [size=${config.translateSize}]${segmentTranslate}[/size]`;
} else {
segmentTranslate = `[size=${config.translateSize}]${segmentTranslate}[/size]`;
}
if (config.translateLayout === 'bottom') {
translatedSegments.push(`${segment}\n${config.translateReference ? "\n" : ""}${segmentTranslate}`);
} else if (config.translateLayout === 'top') {
translatedSegments.push(
`${segmentTranslate}\n${config.translateReference ? "\n" : ""}${segment}`
);
}
}
// 合并结果
return translatedSegments.join('');
};
const processTextArea = () => {
let textarea = document.querySelector(`#${uiIDs.replyControl} textarea`);
let text = textarea.value.trim();
let originalLength = text.length;
if (text.length !== 0 && originalLength >= REQUIRED_CHARS) {
// 检查是否已存在拼音
// const rubyRegex = /(<ruby>[\s\S]*?<\/ruby>)/g;
// 为中文加入翻译
if (config.enableTranslate) {
textarea.value = '开始翻译...';
processTranslateText(text).then(res => {
textarea.value = res;
// 创建并触发 input 事件
const inputEvent = new Event('input', {
bubbles: true,
cancelable: true,
});
// 触发事件
textarea.dispatchEvent(inputEvent);
});
return;
}
textarea.value = text;
// 创建并触发 input 事件
const inputEvent = new Event('input', {
bubbles: true,
cancelable: true,
});
// 触发事件
textarea.dispatchEvent(inputEvent);
}
};
const handleClick = event => {
// 修复翻译两次的 BUG
if (config.enableTranslate) {
return;
}
if (event.target && event.target.closest('button.create')) {
processTextArea();
}
};
let spacePresses = 0;
let lastKeyTime = 0;
let timeoutHandle = null;
const handleKeydown = event => {
// console.log(`KeyboardEvent: key='${event.key}' | code='${event.code}'`);
if (event.ctrlKey && event.key === 'Enter') {
processTextArea();
return;
}
// 使用 Alt+D 触发翻译
if (event.altKey && event.keyCode === 68) {
event.preventDefault(); // 阻止默认行为
processTextArea();
return;
}
const currentTime = Date.now();
if (event.code === 'Space') {
// 如果时间间隔太长,重置计数
if (currentTime - lastKeyTime > SPACE_PRESS_TIMEOUT) {
spacePresses = 1;
} else {
spacePresses += 1;
}
lastKeyTime = currentTime;
// 清除之前的定时器
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
// 设置新的定时器,如果在 SPACE_PRESS_TIMEOUT 毫秒内没有新的按键,则重置计数
timeoutHandle = setTimeout(() => {
spacePresses = 0;
}, SPACE_PRESS_TIMEOUT);
// 检查是否达到了按键次数
if (spacePresses === SPACE_PRESS_COUNT) {
spacePresses = 0; // 重置计数
// 执行翻译操作
processTextArea();
}
} else {
// 如果按下了其他键,重置计数
spacePresses = 0;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
};
const saveConfig = () => {
const customUrlInput = document.getElementById(uiIDs.customUrlInput);
config.customUrl = customUrlInput.value.trim();
const authKeyInput = document.getElementById(uiIDs.authKeyInput);
config.authKey = authKeyInput.value.trim();
const translateModelInput = document.getElementById(uiIDs.translateModelInput);
config.translateModel = translateModelInput.value;
const transalteSizeInput = document.getElementById(uiIDs.translateSizeInput);
config.translateSize = transalteSizeInput.value;
console.log(config);
GM_setValue('customUrl', config.customUrl);
GM_setValue('authKey', config.authKey);
GM_setValue('enableTranslate', config.enableTranslate);
GM_setValue('translateModel', config.translateModel);
GM_setValue('translateSize', config.translateSize);
GM_setValue('translateItalics', config.translateItalics);
GM_setValue('translateBold', config.translateBold);
GM_setValue('translateReference', config.translateReference);
GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
if (config.closeConfigAfterSave) {
const panel = document.getElementById(uiIDs.configPanel);
toggleConfigPanelAnimation(panel);
}
};
const restoreDefaults = () => {
if (confirm('确定要将所有设置恢复为默认值吗?')) {
config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
GM_setValue('maxRetryTimes', config.maxRetryTimes);
GM_setValue('customUrl', config.customUrl);
GM_setValue('authKey', config.authKey);
GM_setValue('enableTranslate', config.enableTranslate);
GM_setValue('translateSourceLang', config.translateSourceLang);
GM_setValue('translateTargetLang', config.translateTargetLang);
GM_setValue('translateModel', config.translateModel);
GM_setValue('translateLayout', config.translateLayout);
GM_setValue('translateSize', config.translateSize);
GM_setValue('translateItalics', config.translateItalics);
GM_setValue('translateBold', config.translateBold);
GM_setValue('translateReference', config.translateReference);
GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
const panel = document.getElementById(uiIDs.configPanel);
if (panel) {
updateConfigPanelContent(panel);
}
}
};
const createFormGroup = (labelText, element) => {
const group = document.createElement('div');
group.className = 'form-group';
const label = document.createElement('label');
label.className = 'form-label';
label.textContent = labelText;
group.appendChild(label);
group.appendChild(element);
return group;
};
const createSelect = (eleId, configId, options, defaultValue, onChange = undefined) => {
const select = document.createElement('select');
select.className = 'modern-select';
select.id = eleId;
options.forEach(option => {
const optionElement = document.createElement('option');
optionElement.value = option.value;
optionElement.textContent = option.text;
select.appendChild(optionElement);
});
select.value = defaultValue;
if (onChange !== undefined) {
select.addEventListener('change', e => onChange(e));
} else {
select.addEventListener('change', e => {
config[configId] = e.target.value;
console.log(`[存储配置] ${configId}: ${config[configId]}`);
GM_setValue(configId, config[configId]);
});
}
return select;
};
const createInput = (eleId, value, type = 'text', placeholder = '') => {
const input = document.createElement('input');
input.className = 'modern-input';
input.id = eleId;
input.type = type;
input.value = value;
input.placeholder = placeholder;
return input;
};
const createSwitch = (eleId, configId, checked, labelText) => {
const container = document.createElement('div');
container.className = 'switch-container';
const label = document.createElement('span');
label.className = 'form-label';
label.style.margin = '0';
label.textContent = labelText;
const switchEl = document.createElement('div');
switchEl.id = eleId;
switchEl.className = `modern-switch${checked ? ' active' : ''}`;
switchEl.addEventListener('click', () => {
switchEl.classList.toggle('active');
config[configId] = switchEl.classList.contains('active');
console.log(`[存储配置] ${configId}: ${config[configId]}`);
GM_setValue(configId, config[configId]);
});
container.appendChild(label);
container.appendChild(switchEl);
return container;
};
const createButton = (text, onClick, variant = 'secondary') => {
const button = document.createElement('button');
button.className = `modern-button ${variant}`;
button.textContent = text;
button.addEventListener('click', onClick);
return button;
};
// const createTextArea = (id, value, labelText, placeholder) => {
// const container = document.createElement('div');
// container.style.marginBottom = '15px';
// const label = document.createElement('label');
// label.textContent = labelText;
// label.style.display = 'block';
// label.style.marginBottom = '5px';
// container.appendChild(label);
// const textarea = document.createElement('textarea');
// textarea.id = id;
// if (typeof value === 'string') {
// textarea.value = value;
// } else {
// textarea.value = JSON.stringify(value, null, 2);
// }
// textarea.placeholder = placeholder;
// textarea.rows = 5;
// textarea.style.width = '100%';
// textarea.style.padding = '5px';
// textarea.style.border = '1px solid var(--panel-border)';
// textarea.style.borderRadius = '4px';
// textarea.style.backgroundColor = 'var(--panel-bg)';
// textarea.style.color = 'var(--panel-text)';
// container.appendChild(textarea);
// return [container, textarea];
// };
const updateConfigPanelContent = (panel, panelContent) => {
panelContent.innerHTML = '';
// 添加表单元素
const translateProviderSelect = createSelect(
uiIDs.translateProviderSelect,
'translateProvider',
TRANSLATE_PROVIDERS,
config.translateProvider,
e => {
config.translateProvider = e.target.value;
const notCustomUrl = NOT_CUSTOM_URL_PROVIDERS.includes(config.translateProvider);
const urlInput = document.getElementById(uiIDs.customUrlInput);
if (notCustomUrl) {
if (urlInput) {
urlInput.disabled = true;
}
} else {
if (urlInput) {
urlInput.disabled = false;
}
}
console.log(`[存储配置] translateProvider: ${config.translateProvider}`);
GM_setValue('translateProvider', config.translateProvider);
}
);
panelContent.appendChild(createFormGroup('翻译服务商(Provider)', translateProviderSelect));
const customUrlInput = createInput(uiIDs.customUrlInput, config.customUrl, 'text', '填写自定义请求地址');
const notCustomUrl = NOT_CUSTOM_URL_PROVIDERS.includes(config.translateProvider);
if (notCustomUrl) {
customUrlInput.disabled = true;
}
panelContent.appendChild(createFormGroup('自定义链接(Custom URL)', customUrlInput));
const authKeyInput = createInput(uiIDs.authKeyInput, config.authKey, 'password', '输入认证密钥');
panelContent.appendChild(createFormGroup('认证密钥(Auth Key)', authKeyInput));
const modelInput = createInput(uiIDs.translateModelInput, config.translateModel, 'text', '输入翻译模型');
panelContent.appendChild(createFormGroup('翻译模型(AI Model)', modelInput));
const targetSourceSelect = createSelect(
uiIDs.translateSourceLangSelect,
'translateSourceLang',
TRANSLATE_TARGET_LANG_OPTIONS,
config.translateSourceLang
);
const targetLangSelect = createSelect(
uiIDs.translateTargetLangSelect,
'translateTargetLang',
TRANSLATE_TARGET_LANG_OPTIONS,
config.translateTargetLang
);
panelContent.appendChild(createFormGroup('源语言(Source Language)', targetSourceSelect));
panelContent.appendChild(createFormGroup('目标语言(Target Language)', targetLangSelect));
const sizeInput = createInput(
uiIDs.translateSizeInput,
config.translateSize,
'number',
'默认值为150(字体大小为原始的150%)'
);
panelContent.appendChild(createFormGroup('翻译字体大小(百分比)', sizeInput));
const layoutSelect = createSelect(
uiIDs.translateLayoutSelect,
'translateLayout',
[
{ text: '翻译在上(Translation On Top)', value: 'top' },
{ text: '翻译在下(Translation On Bottom)', value: 'bottom' },
],
config.translateLayout
);
panelContent.appendChild(createFormGroup('翻译布局(Layout)', layoutSelect));
TRANSLATE_TARGET_LANG_OPTIONS;
// 添加开关
panelContent.appendChild(
createSwitch(uiIDs.enableTranslateSwitch, 'enableTranslate', config.enableTranslate, '启用翻译(Enable Translate)')
);
panelContent.appendChild(
createSwitch(uiIDs.translateItalicsSwitch, 'translateItalics', config.translateItalics, '启用斜体(Enable Italic)')
);
panelContent.appendChild(
createSwitch(uiIDs.translateBoldSwitch, 'translateBold', config.translateBold, '启用粗体(Enable Bold)')
);
panelContent.appendChild(
createSwitch(uiIDs.translateReferenceSwitch, 'translateReference', config.translateReference, '转为引用(Convert to Quote)')
);
panelContent.appendChild(
createSwitch(
uiIDs.closeConfigAfterSaveSwitch,
'closeConfigAfterSave',
config.closeConfigAfterSave,
'保存后自动关闭(Close Panel After Save)'
)
);
// 创建按钮组
const buttonGroup = document.createElement('div');
buttonGroup.className = 'button-group';
buttonGroup.appendChild(createButton('恢复默认', restoreDefaults));
buttonGroup.appendChild(createButton('保存设置', saveConfig, 'primary'));
buttonGroup.appendChild(
createButton(
'翻译(Translate)',
processTextArea, // Call translate function directly
'primary'
)
);
buttonGroup.appendChild(
createButton(
'关闭',
() => {
toggleConfigPanelAnimation(panel);
},
'ghost'
)
);
panelContent.appendChild(buttonGroup);
};
const createConfigPanel = () => {
// 获取页面的 <meta name="theme-color"> 标签
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
let themeColor = '#DDDDDD'; // 默认白色背景
let invertedColor = '#222222'; // 默认黑色字体
if (themeColorMeta) {
themeColor = themeColorMeta.getAttribute('content');
invertedColor = getInvertColor(themeColor); // 计算相反颜色
}
// 设置样式变量
const style = document.createElement('style');
style.textContent = `
:root {
--panel-bg: ${themeColor};
--panel-text: ${invertedColor};
--panel-border: ${invertedColor};
--button-bg: ${invertedColor};
--button-text: ${themeColor};
--button-hover-bg: ${getInvertColor(invertedColor)};
--button-hover-text: ${getInvertColor(themeColor)};
}
.modern-panel {
position: fixed;
top: 80px;
right: 20px;
width: 360px;
background: color-mix(in srgb, var(--panel-bg) 85%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
border: 1px solid color-mix(in srgb, var(--panel-border) 30%, transparent);
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
color: var(--panel-text);
}
.modern-panel.show {
opacity: 1;
transform: translateY(0);
}
.modern-panel-header {
padding: 20px 24px;
border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 10%, transparent);
display: flex;
justify-content: space-between;
align-items: center;
}
.modern-panel-title {
font-size: 18px;
font-weight: 600;
color: var(--panel-text);
margin: 0;
}
.modern-panel-content {
padding: 24px;
max-height: calc(80vh - 140px);
overflow-y: auto;
}
.modern-panel-content::-webkit-scrollbar {
width: 6px;
}
.modern-panel-content::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--panel-border) 20%, transparent);
border-radius: 3px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--panel-text);
margin-bottom: 8px;
}
.modern-select {
width: 100%;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--panel-border) 20%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--panel-bg) 90%, transparent);
color: var(--panel-text);
font-size: 14px;
transition: all 0.2s ease;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
cursor: pointer;
}
.modern-select:hover {
border-color: color-mix(in srgb, var(--panel-border) 40%, transparent);
}
.modern-select:focus {
outline: none;
border-color: var(--button-bg);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--button-bg) 10%, transparent);
}
.modern-input {
width: 100%;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--panel-border) 20%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--panel-bg) 90%, transparent);
color: var(--panel-text);
font-size: 14px;
transition: all 0.2s ease;
}
.modern-input:hover {
border-color: color-mix(in srgb, var(--panel-border) 40%, transparent);
}
.modern-input:focus {
outline: none;
border-color: var(--button-bg);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--button-bg) 10%, transparent);
}
.switch-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modern-switch {
position: relative;
width: 44px;
height: 24px;
background: color-mix(in srgb, var(--panel-border) 20%, transparent);
border-radius: 12px;
padding: 2px;
transition: background 0.3s ease;
cursor: pointer;
}
.modern-switch.active {
background: var(--button-bg);
}
.modern-switch::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 10px;
background: var(--panel-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
transform: translateY(1px) translateX(2px);
}
.modern-switch.active::after {
transform: translateY(1px) translateX(22px);
}
.button-group {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid color-mix(in srgb, var(--panel-border) 10%, transparent);
}
.modern-button {
flex: 1;
padding: 2px 8px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.modern-button.primary {
background: var(--button-bg);
color: var(--button-text);
}
.modern-button.primary:hover {
background: var(--button-hover-bg);
color: var(--button-hover-text);
}
.modern-button.secondary {
background: color-mix(in srgb, var(--panel-border) 10%, transparent);
color: var(--panel-text);
}
.modern-button.secondary:hover {
background: color-mix(in srgb, var(--panel-border) 20%, transparent);
}
.modern-button.ghost {
background: transparent;
color: var(--panel-text);
}
.modern-button.ghost:hover {
background: color-mix(in srgb, var(--panel-border) 10%, transparent);
}
}`;
document.head.appendChild(style);
const panel = document.createElement('div');
panel.id = uiIDs.configPanel;
panel.className = 'modern-panel';
panel.style.display = 'none';
// 创建头部
const header = document.createElement('div');
header.className = 'modern-panel-header';
const title = document.createElement('h3');
title.className = 'modern-panel-title';
title.textContent = '设置';
header.appendChild(title);
// 创建内容区域
const content = document.createElement('div');
content.className = 'modern-panel-content';
console.log();
updateConfigPanelContent(panel, content);
panel.appendChild(header);
panel.appendChild(content);
document.body.appendChild(panel);
return panel;
};
const toggleConfigPanelAnimation = panel => {
if (panel.style.display === 'none') {
panel.classList.add('show');
setTimeout(() => {
panel.style.display = 'block';
}, 10);
} else {
panel.classList.remove('show');
setTimeout(() => {
panel.style.display = 'none';
}, 300);
}
};
const toggleConfigPanel = () => {
let panel = document.getElementById(uiIDs.configPanel);
panel = panel || createConfigPanel();
toggleConfigPanelAnimation(panel);
};
const createConfigButton = () => {
const toolbar = document.querySelector('.d-editor-button-bar');
if (!toolbar || document.getElementById(uiIDs.configButton)) return;
const configButton = document.createElement('button');
configButton.id = uiIDs.configButton;
configButton.className = 'btn btn-flat btn-icon no-text user-menu-tab active';
configButton.title = '配置';
configButton.innerHTML =
'<svg class="fa d-icon d-icon-discourse-other-tab svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#discourse-other-tab"></use></svg>';
configButton.onclick = toggleConfigPanel;
toolbar.appendChild(configButton);
};
const watchReplyControl = () => {
const replyControl = document.getElementById(uiIDs.replyControl);
if (!replyControl) return;
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
if (replyControl.classList.contains('closed')) {
const panel = document.getElementById(uiIDs.configPanel);
if (panel) {
panel.style.display = 'none';
}
} else {
// 当 reply-control 重新打开时,尝试添加配置按钮
setTimeout(createConfigButton, 500); // 给予一些时间让编辑器完全加载
}
}
});
});
observer.observe(replyControl, { attributes: true });
};
const watchForEditor = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
const addedNodes = mutation.addedNodes;
for (let node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('d-editor')) {
createConfigButton();
return;
}
}
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
};
const init = () => {
const container = document.getElementById(uiIDs.replyControl);
container.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeydown, true);
if (!document.getElementById(uiIDs.configButton)) {
createConfigButton();
}
watchReplyControl();
watchForEditor();
};
// 初始化
setTimeout(() => {
init();
}, 1000);
})();