您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Translate selected text on the page into Japanese( is hard-coded) using OpenRouter API( model type is also hard-coded).
// ==UserScript== // @name OpenRouter Inline Translator // @name:en OpenRouter Inline Translator // @namespace http://tampermonkey.net/ // @license MIT // @version 1.3 // @description Translate selected text on the page into Japanese( is hard-coded) using OpenRouter API( model type is also hard-coded). // @description:en Translate selected text on the page into Japanese( is hard-coded) using OpenRouter API( model type is also hard-coded). // @author chainsaw-clara-beau // @match *://*/* // @connect openrouter.ai // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; // --- CSS Styles from content.css --- const styles = ` #openrouter-translator-small-icon-popup { all: unset; display: block; position: absolute; z-index: 2147483647; cursor: pointer; background-color: rgba(240, 240, 240, 0.95); border: 1px solid #ccc; border-radius: 15px; padding: 3px 6px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1; } #openrouter-translator-small-icon-popup .emoji-trigger { } .openrouter-translator-detailed-popup { all: unset; display: block; position: absolute; z-index: 2147483647; background-color: #0b0d0f; color: #abb2bf; border: 1px solid #444c56; border-radius: 8px; padding: 15px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); font-family: sans-serif; font-size: 14px; width: 400px; min-width: 200px; min-height: 100px; box-sizing: border-box; display: flex; flex-direction: column; } .openrouter-translator-detailed-popup .popup-close-button { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 22px; color: #abb2bf; cursor: pointer; padding: 0; line-height: 1; z-index: 10; font-weight: bold; } .openrouter-translator-detailed-popup .popup-close-button:hover { color: #e06c75; } .openrouter-translator-detailed-popup .resize-handle { position: absolute; background: transparent; z-index: 5; } .openrouter-translator-detailed-popup .resize-handle-e { top: 0; right: 0; width: 10px; height: 100%; cursor: e-resize; } .openrouter-translator-detailed-popup .resize-handle-s { bottom: 0; left: 0; width: 100%; height: 10px; cursor: s-resize; } .openrouter-translator-detailed-popup .resize-handle-se { bottom: 0; right: 0; width: 12px; height: 12px; cursor: se-resize; z-index: 6; } .openrouter-translator-detailed-popup .translator-popup-content { display: flex; flex-direction: column; flex-grow: 1; overflow: hidden; margin-top: 15px; } .openrouter-translator-detailed-popup .language-selector { display: flex; align-items: center; margin-bottom: 10px; } .openrouter-translator-detailed-popup .language-selector label { margin-right: 8px; color: #98c379; } .openrouter-translator-detailed-popup select { flex-grow: 1; padding: 8px; border: 1px solid #444c56; border-radius: 4px; background-color: #0b0d0f; color: #abb2bf; } .openrouter-translator-detailed-popup button:not(.popup-close-button) { background-color: #61afef; color: #282c34; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px; } .openrouter-translator-detailed-popup button:not(.popup-close-button):hover { background-color: #5299d8; } .openrouter-translator-detailed-popup #inlineLoadingIndicator { text-align: center; color: #e5c07b; margin: 10px 0; } .openrouter-translator-detailed-popup .translation-output { background-color: #0b0d0f; padding: 10px; border-radius: 4px; min-height: 40px; overflow-wrap: break-word; white-space: pre-wrap; font-size: 0.95em; max-height: 400px; overflow-y: auto; flex-grow: 1; scrollbar-width: thin; scrollbar-color: #6e7886 #0b0d0f; } .openrouter-translator-detailed-popup .translation-output::-webkit-scrollbar { width: 8px; } .openrouter-translator-detailed-popup .translation-output::-webkit-scrollbar-track { background: #0b0d0f; border-radius: 10px; margin: 2px 0; } .openrouter-translator-detailed-popup .translation-output::-webkit-scrollbar-thumb { background-color: #6e7886; border-radius: 10px; border: 2px solid #0b0d0f; } .openrouter-translator-detailed-popup .translation-output::-webkit-scrollbar-thumb:hover { background-color: #818c99; } .openrouter-translator-detailed-popup .translation-output::-webkit-scrollbar-button { display: none; } .openrouter-translator-detailed-popup .translation-output::-webkit-scrollbar-corner { background: transparent; display: none; } `; GM_addStyle(styles); // --- Logic from background.js and content.js --- // Configuration const DEFAULT_POPUP_WIDTH = 400; const DEFAULT_POPUP_HEIGHT = 300; // Global state let smallIconPopup = null; let selectedTextGlobal = ''; let popupIdCounter = 0; let activeInteraction = { element: null, isDragging: false, isResizing: false, resizeType: '', dragStartX: 0, dragStartY: 0, popupStartX: 0, popupStartY: 0, startWidth: 0, startHeight: 0, startX: 0, startY: 0, }; // --- API & Storage Handling (was background.js) --- async function getApiKey() { return await GM_getValue('openrouterApiKey', null); } async function saveApiKey(apiKey) { await GM_setValue('openrouterApiKey', apiKey); return { success: true }; } async function translateTextWithOpenRouter(text, targetLanguage, apiKey) { if (!apiKey) { return { error: 'APIキーが設定されていません。ユーザースクリプトのメニューから設定してください。' }; } if (!text) { return { error: '翻訳するテキストが入力されていません。' }; } const API_URL = 'https://openrouter.ai/api/v1/chat/completions'; let systemPrompt = `Please translate the following text to ${targetLanguage}. Make it natural and avoid literal translation.`; switch (targetLanguage) { case 'Japanese': systemPrompt = '以下の文章を日本語訳してください。なるべく直訳は避け自然な日本語にしてください。'; break; case 'English': systemPrompt = 'Please translate the following text to English. Make it natural and avoid literal translation.'; break; case 'Korean': systemPrompt = '다음 문장을 한국어로 번역해주세요. 직역보다는 자연스러운 한국어로 번역해주세요.'; break; case 'Chinese': systemPrompt = '请将以下文本翻译成中文。请避免直译,使用自然的中文表达。'; break; } return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: API_URL, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'X-Title': 'OpenRouter Translator Userscript' }, data: JSON.stringify({ model: "google/gemini-2.5-flash", messages: [ { role: "system", content: systemPrompt }, { role: "user", content: text } ], max_tokens: 4000, temperature: 0.3 }), onload: function (response) { if (response.status >= 200 && response.status < 300) { const data = JSON.parse(response.responseText); if (data.choices && data.choices.length > 0 && data.choices[0].message) { resolve({ translatedText: data.choices[0].message.content.trim() }); } else { console.error('OpenRouter API Response format error:', data); resolve({ error: 'APIからの応答形式が正しくありません。' }); } } else { const errorData = JSON.parse(response.responseText); console.error('OpenRouter API Error:', errorData); let errorMessage = `APIエラー: ${response.status}`; if (errorData && errorData.error && errorData.error.message) { errorMessage += ` - ${errorData.error.message}`; } resolve({ error: errorMessage }); } }, onerror: function (error) { console.error('Fetch Error:', error); resolve({ error: `ネットワークエラーまたはリクエスト失敗: ${error.statusText}` }); } }); }); } // --- UI and Interaction Logic (was content.js) --- function removeDetailedPopup(popupElement) { if (popupElement) popupElement.remove(); } function removeSmallIconPopup() { if (smallIconPopup) { smallIconPopup.remove(); smallIconPopup = null; } } async function getSavedPopupSize() { const width = await GM_getValue('popupWidth', DEFAULT_POPUP_WIDTH); const height = await GM_getValue('popupHeight', DEFAULT_POPUP_HEIGHT); return { width, height }; } function savePopupSize(width, height) { GM_setValue('popupWidth', width); GM_setValue('popupHeight', height); } // Core function to trigger translation async function triggerTranslation(text, x, y) { if (!text || text.length === 0) { alert("No text selected."); return; } selectedTextGlobal = text; if (typeof x !== "number" || typeof y !== "number") { x = window.innerWidth / 2 - (DEFAULT_POPUP_WIDTH / 2); y = window.innerHeight / 2 - (DEFAULT_POPUP_HEIGHT / 2); } const newDetailedPopup = await createDetailedPopup(x, y, text, true); const apiKey = await getApiKey(); const response = await translateTextWithOpenRouter(text, "Japanese", apiKey); if (!newDetailedPopup) return; const loadingIndicator = newDetailedPopup.querySelector('#inlineLoadingIndicator'); const outputArea = newDetailedPopup.querySelector('#inlineTranslationOutput'); if (loadingIndicator) loadingIndicator.style.display = 'none'; if (!outputArea) return; if (response.error) { outputArea.textContent = 'エラー: ' + response.error; } else if (response.translatedText) { outputArea.textContent = response.translatedText; } else { outputArea.textContent = '翻訳結果がありません。'; } } function createSmallIconPopup(x, y) { removeSmallIconPopup(); smallIconPopup = document.createElement('div'); smallIconPopup.id = 'openrouter-translator-small-icon-popup'; smallIconPopup.innerHTML = `<span class="emoji-trigger" title="翻訳する">🌐</span>`; document.body.appendChild(smallIconPopup); smallIconPopup.style.left = `${x}px`; smallIconPopup.style.top = `${y}px`; smallIconPopup.addEventListener('click', async (event) => { event.stopPropagation(); if (selectedTextGlobal) { const iconRect = smallIconPopup.getBoundingClientRect(); if (smallIconPopup) smallIconPopup.style.display = 'none'; await triggerTranslation(selectedTextGlobal, iconRect.left + window.scrollX, iconRect.bottom + window.scrollY + 5); } }); } async function createDetailedPopup(x, y, originalText, isLoading = false) { const popupElement = document.createElement('div'); popupElement.className = 'openrouter-translator-detailed-popup'; popupElement.dataset.popupId = `popup-translator-${popupIdCounter++}`; popupElement.innerHTML = ` <button class="popup-close-button" title="閉じる">×</button> <div class="translator-popup-content"> <div id="inlineLoadingIndicator" style="display: ${isLoading ? 'block' : 'none'};">...</div> <div id="inlineTranslationOutput" class="translation-output"></div> </div> <div class="resize-handle resize-handle-e"></div> <div class="resize-handle resize-handle-s"></div> <div class="resize-handle resize-handle-se"></div> `; document.body.appendChild(popupElement); popupElement.style.left = `${x}px`; popupElement.style.top = `${y}px`; popupElement.style.zIndex = 10000 + popupIdCounter; const { width, height } = await getSavedPopupSize(); popupElement.style.width = `${width}px`; popupElement.style.height = `${height}px`; setupPopupInteractions(popupElement); return popupElement; } function setupPopupInteractions(popupElement) { const dragHandle = popupElement; popupElement.addEventListener('mousedown', () => { popupElement.style.zIndex = 10000 + popupIdCounter++; }, true); dragHandle.addEventListener('mousedown', (e) => { if (e.target.closest('button, .translation-output, .resize-handle, .popup-close-button')) return; e.preventDefault(); activeInteraction = { isDragging: true, element: popupElement, dragStartX: e.clientX, dragStartY: e.clientY, popupStartX: popupElement.offsetLeft, popupStartY: popupElement.offsetTop, }; popupElement.style.userSelect = 'none'; }); popupElement.querySelector('.popup-close-button').addEventListener('click', (e) => { e.stopPropagation(); removeDetailedPopup(popupElement); }); setupResizeHandlers(popupElement); } function setupResizeHandlers(popupElement) { const eastResize = popupElement.querySelector('.resize-handle-e'); const southResize = popupElement.querySelector('.resize-handle-s'); const southEastResize = popupElement.querySelector('.resize-handle-se'); if (!eastResize || !southResize || !southEastResize) return; const startResize = (e, type) => { e.preventDefault(); e.stopPropagation(); activeInteraction = { isResizing: true, resizeType: type, element: popupElement, startX: e.clientX, startY: e.clientY, startWidth: popupElement.offsetWidth, startHeight: popupElement.offsetHeight, }; document.body.style.cursor = `${type}-resize`; }; eastResize.addEventListener('mousedown', (e) => startResize(e, 'e')); southResize.addEventListener('mousedown', (e) => startResize(e, 's')); southEastResize.addEventListener('mousedown', (e) => startResize(e, 'se')); } document.addEventListener('mousemove', (e) => { if (!activeInteraction.element) return; if (activeInteraction.isDragging) { const dx = e.clientX - activeInteraction.dragStartX; const dy = e.clientY - activeInteraction.dragStartY; activeInteraction.element.style.left = `${activeInteraction.popupStartX + dx}px`; activeInteraction.element.style.top = `${activeInteraction.popupStartY + dy}px`; } if (activeInteraction.isResizing) { if (activeInteraction.resizeType.includes('e')) { const width = activeInteraction.startWidth + (e.clientX - activeInteraction.startX); if (width >= 200) activeInteraction.element.style.width = `${width}px`; } if (activeInteraction.resizeType.includes('s')) { const height = activeInteraction.startHeight + (e.clientY - activeInteraction.startY); if (height >= 100) activeInteraction.element.style.height = `${height}px`; } } }); document.addEventListener('mouseup', () => { if (!activeInteraction.element) return; if (activeInteraction.isDragging) { activeInteraction.element.style.userSelect = 'auto'; } if (activeInteraction.isResizing) { document.body.style.cursor = 'default'; const width = activeInteraction.element.offsetWidth; const height = activeInteraction.element.offsetHeight; savePopupSize(width, height); } activeInteraction = { element: null, isDragging: false, isResizing: false }; }); document.addEventListener('mousedown', (event) => { if (event.target.closest('.openrouter-translator-detailed-popup, #openrouter-translator-small-icon-popup')) { return; } removeSmallIconPopup(); }); document.addEventListener('mouseup', (event) => { if (event.target.closest('.openrouter-translator-detailed-popup, #openrouter-translator-small-icon-popup')) { return; } setTimeout(() => { const currentSelectedText = window.getSelection().toString().trim(); if (currentSelectedText.length > 0) { selectedTextGlobal = currentSelectedText; const selection = window.getSelection(); if (selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); createSmallIconPopup(rect.right + window.scrollX - 10, rect.top + window.scrollY - 10); } }, 0); }); // --- Context Menu --- GM_registerMenuCommand("Set OpenRouter API Key", async () => { const currentKey = await getApiKey() || ''; const newKey = prompt("Enter your OpenRouter API Key:", currentKey); if (newKey !== null) { // Check if user cancelled await saveApiKey(newKey.trim()); alert("API Key saved."); } }); })();