OpenRouter Inline Translator

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="閉じる">&times;</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.");
        }
    });

})();