Greasy Fork

Greasy Fork is available in English.

Manga Translator (Gemini) - Improved

Translate manga with Gemini, with improved UX, client-side resizing, and more robust coordinate scaling.

当前为 2025-05-13 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Manga Translator (Gemini) - Improved
// @namespace    http://tampermonkey.net/
// @version      1.5.20250513
// @description  Translate manga with Gemini, with improved UX, client-side resizing, and more robust coordinate scaling.
// @author       Your Name (Improved by AI)
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      generativelanguage.googleapis.com
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_PREFIX = 'manga_translator_';
    const SCRIPT_VERSION = '1.5.20250513';

    // --- Configuration Constants ---
    const MIN_IMAGE_DIMENSION = 600; // Minimum width or height for an image to be considered for translation
    const GEMINI_API_KEY_STORAGE = SCRIPT_PREFIX + 'gemini_api_key';
    const GEMINI_MAX_PROCESSING_DIMENSION = 1024; // Max dimension (width or height) for image sent to Gemini if resizing is enabled
    const SHOULD_RESIZE_LARGE_IMAGES_BEFORE_UPLOAD = true; // Resize images larger than GEMINI_MAX_PROCESSING_DIMENSION client-side
    const IMAGE_RESIZE_QUALITY = 0.90; // JPEG quality if resizing
    const BBOX_EXPANSION_PIXELS = 6;   // Expand bounding boxes slightly for better text fit
    const ABSOLUTE_MIN_RESIZE_DIMENSION = 30; // Absolute minimum dimension for a resizable bbox (px)
    const BBOX_FONT_SIZE = '15px'; // Default font size for translated text

    let activeImageTarget = null;
    let translateIconElement = null; // Store the current translate icon to show messages

    // Variables for dragging
    let isDragging = false;
    let activeDraggableBox = null;
    let dragOffsetX = 0;
    let dragOffsetY = 0;

    // Variables for resizing
    let isResizing = false;
    let activeResizeBox = null;
    let activeResizeHandle = null;
    let initialResizeMouseX = 0;
    let initialResizeMouseY = 0;
    let initialResizeBoxWidth = 0;
    let initialResizeBoxHeight = 0;
    let minResizeWidth = 0;
    let minResizeHeight = 0;
    let maxResizeWidth = 0;
    let maxResizeHeight = 0;

    const style = document.createElement('style');
    style.textContent = `
        @import url('https://fonts.googleapis.com/css2?family=Patrick+Hand&family=Comic+Neue:wght@400;700&display=swap');

        .${SCRIPT_PREFIX}translate_icon {
            position: absolute;
            top: 10px;
            right: 10px;
            background-color: rgba(0, 0, 0, 0.75);
            color: white;
            padding: 6px 10px;
            border-radius: 5px;
            cursor: pointer;
            font-family: Arial, sans-serif;
            font-size: 13px;
            z-index: 100000; /* Ensure icon is above overlays but below active dragging bbox */
            border: 1px solid rgba(255,255,255,0.5);
            box-shadow: 0 1px 3px rgba(0,0,0,0.3);
            transition: background-color 0.2s, border-color 0.2s;
        }
        .${SCRIPT_PREFIX}translate_icon:hover {
            background-color: rgba(0, 0, 0, 0.9);
            border-color: white;
        }
        .${SCRIPT_PREFIX}translate_icon.processing,
        .${SCRIPT_PREFIX}translate_icon.error {
            cursor: wait !important;
            background-color: #d35400; /* Orange for processing/error */
        }
        .${SCRIPT_PREFIX}translate_icon.success {
            background-color: #27ae60; /* Green for success */
        }
        .${SCRIPT_PREFIX}overlay_container {
            position: absolute;
            pointer-events: none; /* Container itself should not catch mouse events */
            overflow: hidden;
            z-index: 9999; /* Below the icon */
        }
        .${SCRIPT_PREFIX}bbox {
            position: absolute;
            background-color: white;
            color: black;
            font-family: "Patrick Hand", "Comic Neue", cursive, sans-serif;
            font-size: ${BBOX_FONT_SIZE};
            font-weight: 400;
            text-align: center;
            overflow: hidden;
            box-sizing: border-box;
            pointer-events: all !important; /* BBoxes should be interactive */
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 4px 6px;
            border-radius: 15%; /* A bit less round */
            box-shadow: 0 0 0 1.5px white, 0 0 0 3px white; /* Double outline for better visibility */
            line-height: 1.15;
            letter-spacing: -0.03em;
            cursor: grab;
        }
        .${SCRIPT_PREFIX}bbox_dragging {
            cursor: grabbing !important;
            opacity: 0.85;
            z-index: 100001 !important; /* Above icon when dragging */
            user-select: none;
        }
        .${SCRIPT_PREFIX}resize_handle {
            position: absolute;
            width: 12px;
            height: 12px;
            background-color: rgba(0, 100, 255, 0.6);
            border: 1px solid rgba(255,255,255,0.8);
            border-radius: 0;
            z-index: 100002; /* Above bbox */
            pointer-events: all;
            opacity: 0;
            visibility: hidden;
            transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
        }
        .${SCRIPT_PREFIX}bbox:hover .${SCRIPT_PREFIX}resize_handle,
        .${SCRIPT_PREFIX}resize_handle_active {
            opacity: 1;
            visibility: visible;
        }
        .${SCRIPT_PREFIX}resize_handle_br {
            bottom: -1px;
            right: -1px;
            cursor: nwse-resize;
        }
    `;
    document.head.appendChild(style);

    // --- UX Functions ---
    function showTemporaryMessageOnIcon(icon, message, isError = false, duration = 3500) {
        if (!icon) return;
        const originalText = icon.dataset.originalText || 'Dịch';
        const originalBg = icon.style.backgroundColor;

        icon.textContent = message;
        icon.classList.remove('success', 'error', 'processing');
        if (isError) icon.classList.add('error');
        else icon.classList.add('processing'); // Use 'processing' style for general messages too

        setTimeout(() => {
            if (icon.textContent === message) { // Avoid resetting if another message came in
                icon.textContent = originalText;
                icon.classList.remove('success', 'error', 'processing');
                icon.style.backgroundColor = originalBg;
            }
        }, duration);
    }

    function promptAndSetApiKey() {
        const apiKey = prompt('Vui lòng nhập Google AI Gemini API Key của bạn:', GM_getValue(GEMINI_API_KEY_STORAGE, ''));
        if (apiKey) {
            GM_setValue(GEMINI_API_KEY_STORAGE, apiKey);
            console.log("Manga Translator: Gemini API Key saved.");
            if (translateIconElement) showTemporaryMessageOnIcon(translateIconElement, "Đã lưu API Key!", false, 2000);
        } else if (apiKey === "") { // User cleared the key
            GM_setValue(GEMINI_API_KEY_STORAGE, "");
            console.log("Manga Translator: Gemini API Key cleared.");
             if (translateIconElement) showTemporaryMessageOnIcon(translateIconElement, "Đã xóa API Key!", false, 2000);
        }
    }
    GM_registerMenuCommand("Cài đặt/Cập nhật API Key Gemini", promptAndSetApiKey);

    function getGeminiApiKey(iconForMessages) {
        const apiKey = GM_getValue(GEMINI_API_KEY_STORAGE);
        if (!apiKey) {
            if (iconForMessages) showTemporaryMessageOnIcon(iconForMessages, "Chưa có API Key!", true, 5000);
            console.warn("Manga Translator: Gemini API Key is missing. Please set it via the Tampermonkey menu.");
            promptAndSetApiKey(); // Prompt once if missing
            return GM_getValue(GEMINI_API_KEY_STORAGE); // return potentially new key
        }
        return apiKey;
    }


    // --- Drag and Resize Handlers (largely unchanged, minor checks) ---
    function onDragStart(event) {
        if (event.target.classList.contains(`${SCRIPT_PREFIX}resize_handle`)) return;
        if (event.button !== 0) return; // Only main (left) mouse button

        activeDraggableBox = this;
        isDragging = true;
        const currentLeft = parseFloat(activeDraggableBox.style.left || 0);
        const currentTop = parseFloat(activeDraggableBox.style.top || 0);
        dragOffsetX = event.clientX - currentLeft;
        dragOffsetY = event.clientY - currentTop;

        activeDraggableBox.classList.add(`${SCRIPT_PREFIX}bbox_dragging`);
        document.addEventListener('mousemove', onDragMove);
        document.addEventListener('mouseup', onDragEnd);
        document.addEventListener('mouseleave', onDocumentMouseLeave); // Use a more generic name
        event.preventDefault();
    }

    function onDragMove(event) {
        if (!isDragging || !activeDraggableBox) return;
        event.preventDefault();

        const overlayContainer = activeDraggableBox.parentNode;
        if (!overlayContainer || !(overlayContainer instanceof HTMLElement)) return;

        let newLeft = event.clientX - dragOffsetX;
        let newTop = event.clientY - dragOffsetY;

        // Constrain within parent overlay container
        const maxLeft = overlayContainer.offsetWidth - activeDraggableBox.offsetWidth;
        const maxTop = overlayContainer.offsetHeight - activeDraggableBox.offsetHeight;
        newLeft = Math.max(0, Math.min(newLeft, maxLeft));
        newTop = Math.max(0, Math.min(newTop, maxTop));

        activeDraggableBox.style.left = newLeft + 'px';
        activeDraggableBox.style.top = newTop + 'px';
    }

    function onDragEnd() {
        if (!isDragging) return;
        isDragging = false;
        if (activeDraggableBox) {
            activeDraggableBox.classList.remove(`${SCRIPT_PREFIX}bbox_dragging`);
        }
        activeDraggableBox = null;
        document.removeEventListener('mousemove', onDragMove);
        document.removeEventListener('mouseup', onDragEnd);
        document.removeEventListener('mouseleave', onDocumentMouseLeave);
    }

    function onResizeStart(event) {
        if (event.button !== 0) return;
        event.stopPropagation();
        event.preventDefault();

        activeResizeHandle = this;
        activeResizeBox = this.parentNode; // The bbox div
        isResizing = true;

        initialResizeMouseX = event.clientX;
        initialResizeMouseY = event.clientY;
        initialResizeBoxWidth = activeResizeBox.offsetWidth;
        initialResizeBoxHeight = activeResizeBox.offsetHeight;

        // Resize limits (percentage of initial size)
        minResizeWidth = initialResizeBoxWidth * 0.2;
        minResizeHeight = initialResizeBoxHeight * 0.2;
        maxResizeWidth = initialResizeBoxWidth * 2.5; // Allow slightly larger max resize
        maxResizeHeight = initialResizeBoxHeight * 2.5;

        activeResizeBox.classList.add(`${SCRIPT_PREFIX}bbox_dragging`); // Use same visual cue
        activeResizeHandle.classList.add(`${SCRIPT_PREFIX}resize_handle_active`);

        document.addEventListener('mousemove', onResizeMove);
        document.addEventListener('mouseup', onResizeEnd);
        document.addEventListener('mouseleave', onDocumentMouseLeave);
    }

    function onResizeMove(event) {
        if (!isResizing || !activeResizeBox) return;
        event.preventDefault();

        const deltaX = event.clientX - initialResizeMouseX;
        const deltaY = event.clientY - initialResizeMouseY;

        let newWidth = initialResizeBoxWidth + deltaX;
        let newHeight = initialResizeBoxHeight + deltaY;

        // 1. Apply percentage limits
        newWidth = Math.max(minResizeWidth, Math.min(newWidth, maxResizeWidth));
        newHeight = Math.max(minResizeHeight, Math.min(newHeight, maxResizeHeight));

        // 2. Apply limits of the overlayContainer
        const overlayContainer = activeResizeBox.parentNode;
        if (overlayContainer && overlayContainer instanceof HTMLElement) {
            const currentBoxLeft = parseFloat(activeResizeBox.style.left || 0);
            const currentBoxTop = parseFloat(activeResizeBox.style.top || 0);
            const maxContainerWidth = overlayContainer.offsetWidth - currentBoxLeft;
            const maxContainerHeight = overlayContainer.offsetHeight - currentBoxTop;
            newWidth = Math.min(newWidth, maxContainerWidth);
            newHeight = Math.min(newHeight, maxContainerHeight);
        }

        // 3. Apply absolute minimum dimension
        newWidth = Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION, newWidth);
        newHeight = Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION, newHeight);

        activeResizeBox.style.width = newWidth + 'px';
        activeResizeBox.style.height = newHeight + 'px';
    }

    function onResizeEnd() {
        if (!isResizing) return;
        isResizing = false;
        if (activeResizeBox) {
            activeResizeBox.classList.remove(`${SCRIPT_PREFIX}bbox_dragging`);
        }
        if (activeResizeHandle) {
            activeResizeHandle.classList.remove(`${SCRIPT_PREFIX}resize_handle_active`);
        }
        activeResizeBox = null;
        activeResizeHandle = null;
        document.removeEventListener('mousemove', onResizeMove);
        document.removeEventListener('mouseup', onResizeEnd);
        document.removeEventListener('mouseleave', onDocumentMouseLeave);
    }

    // Handles mouse leaving the document during drag/resize
    function onDocumentMouseLeave(event) {
        if (isDragging) onDragEnd();
        if (isResizing) onResizeEnd();
    }

    // --- Core Translation Logic ---
    function removeAllOverlays(imgElement) {
        const parentNode = imgElement.parentNode;
        if (parentNode) {
            // Remove all overlay containers associated with THIS script instance for the image
            const existingContainers = parentNode.querySelectorAll(`.${SCRIPT_PREFIX}overlay_container[data-target-img-src="${imgElement.src}"]`);
            existingContainers.forEach(container => container.remove());
        }
    }

    /**
     * Fetches image as a base64 data URL.
     * @param {string} imageUrl
     * @returns {Promise<{dataUrl: string, base64Content: string, mimeType: string}>}
     */
    async function getImageData(imageUrl) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: imageUrl,
                responseType: 'blob',
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        const blob = response.response;
                        const reader = new FileReader();
                        reader.onloadend = () => {
                            const dataUrl = reader.result; // Full data URL: "data:mime/type;base64,..."
                            const base64Content = dataUrl.split(',')[1];
                            resolve({ dataUrl: dataUrl, base64Content: base64Content, mimeType: blob.type || 'image/jpeg' });
                        };
                        reader.onerror = (err) => reject(new Error("FileReader error: " + err));
                        reader.readAsDataURL(blob);
                    } else {
                        reject(new Error(`Failed to fetch image. Status: ${response.status} ${response.statusText}`));
                    }
                },
                onerror: function(error) {
                    reject(new Error(`GM_xmlhttpRequest error: ${error.statusText || 'Network error'}`));
                },
                ontimeout: function() {
                    reject(new Error("GM_xmlhttpRequest timeout fetching image."));
                }
            });
        });
    }

    /**
     * Resizes an image if it's larger than specified dimensions.
     * @param {string} originalDataUrl Full data URL of the original image
     * @param {number} originalWidth
     * @param {number} originalHeight
     * @param {number} maxWidth Max width for resizing
     * @param {number} maxHeight Max height for resizing
     * @param {string} targetMimeType Mime type for the resized image (e.g., 'image/jpeg')
     * @returns {Promise<{base64Data: string, processedWidth: number, processedHeight: number}>}
     */
    async function resizeImageIfNeeded(originalDataUrl, originalWidth, originalHeight, maxWidth, maxHeight, targetMimeType) {
        return new Promise((resolve, reject) => {
            if (!SHOULD_RESIZE_LARGE_IMAGES_BEFORE_UPLOAD || (originalWidth <= maxWidth && originalHeight <= maxHeight)) {
                // No resize needed, return original (or rather, its base64 part)
                resolve({ base64Data: originalDataUrl.split(',')[1], processedWidth: originalWidth, processedHeight: originalHeight });
                return;
            }

            const img = new Image();
            img.onload = () => {
                let ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
                const resizedWidth = Math.floor(originalWidth * ratio);
                const resizedHeight = Math.floor(originalHeight * ratio);

                const canvas = document.createElement('canvas');
                canvas.width = resizedWidth;
                canvas.height = resizedHeight;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0, resizedWidth, resizedHeight);

                resolve({
                    base64Data: canvas.toDataURL(targetMimeType, IMAGE_RESIZE_QUALITY).split(',')[1],
                    processedWidth: resizedWidth,
                    processedHeight: resizedHeight
                });
            };
            img.onerror = (err) => reject(new Error("Failed to load image from data URL for resizing. " + err));
            img.src = originalDataUrl;
        });
    }

    async function callGeminiApi(base64ImageData, apiKey, imageMimeType, naturalImgWidth, naturalImgHeight) {
        // Prompt always refers to natural image dimensions for bbox coordinates
        const promptText = `The original image has dimensions ${naturalImgWidth}x${naturalImgHeight} pixels. Identify all speech bubbles in this manga image. For each identified bubble, extract the Japanese text and translate it to Vietnamese. When translating, please consider the typical conversational flow and context of a manga story to make the translation sound natural and appropriate for the characters involved. Return the translated text along with the bounding box coordinates (x, y, width, height) of the *original* speech bubble, ensuring these coordinates are relative to the *original image dimensions (${naturalImgWidth}x${naturalImgHeight})*. Format the output as a JSON array of objects, where each object has 'text' (translated) and 'bbox' ({x: ..., y: ..., width: ..., height: ...}). If no speech bubbles are found, return an empty array.`;
        const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${apiKey}`; // Example: using 1.5 flash
        const payload = {
            contents: [{
                parts: [
                    { text: promptText },
                    { inline_data: { mime_type: imageMimeType, data: base64ImageData } }
                ]
            }]
        };

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: url,
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(payload),
                timeout: 90000, // Increased timeout for potentially larger images / complex scenes
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const responseData = JSON.parse(response.responseText);
                            if (responseData.candidates && responseData.candidates.length > 0 &&
                                responseData.candidates[0].content && responseData.candidates[0].content.parts &&
                                responseData.candidates[0].content.parts.length > 0) {
                                let resultText = responseData.candidates[0].content.parts[0].text;
                                resultText = resultText.trim().replace(/^```json\s*/, '').replace(/\s*```$/, '');
                                resolve(JSON.parse(resultText));
                            } else if (responseData.promptFeedback && responseData.promptFeedback.blockReason) {
                                reject(new Error(`API blocked: ${responseData.promptFeedback.blockReason} - ${responseData.promptFeedback.blockReasonMessage || 'No message.'}`));
                            } else {
                                resolve([]); // No bubbles found or empty response
                            }
                        } catch (e) {
                            reject(new Error(`Parse Error: ${e.message}. Response: ${response.responseText.substring(0, 200)}...`));
                        }
                    } else {
                        let errorMsg = `API Error: ${response.status} - ${response.statusText}`;
                        try {
                            const errorData = JSON.parse(response.responseText);
                            errorMsg = `API Error: ${response.status} - ${errorData.error?.message || response.statusText}`;
                        } catch (e) { /* Ignore if error response is not JSON */ }
                        reject(new Error(errorMsg));
                    }
                },
                onerror: function(error) { reject(new Error(`Network/CORS Error: ${error.statusText || 'Unknown'}`)); },
                ontimeout: function() { reject(new Error("Gemini API request timed out.")); }
            });
        });
    }

    function displayTranslations(imgElement, translations) {
        removeAllOverlays(imgElement); // Clear previous overlays for this image
        if (!translations || translations.length === 0) return;

        const parentNode = imgElement.parentNode;
        if (!parentNode) return;

        const parentStyle = getComputedStyle(parentNode);
        if (parentStyle.position === 'static') parentNode.style.position = 'relative';

        const imgRect = imgElement.getBoundingClientRect(); // Current displayed size and position
        const naturalWidth = imgElement.naturalWidth;    // Original image width
        const naturalHeight = imgElement.naturalHeight;  // Original image height

        if (naturalWidth === 0 || naturalHeight === 0) {
            console.error("Manga Translator: Image natural dimensions are zero, cannot display translations.");
            return;
        }

        const overlayContainer = document.createElement('div');
        overlayContainer.className = `${SCRIPT_PREFIX}overlay_container`;
        overlayContainer.dataset.targetImgSrc = imgElement.src; // Associate with image
        overlayContainer.style.top = `${imgElement.offsetTop}px`; // Position relative to parentNode
        overlayContainer.style.left = `${imgElement.offsetLeft}px`;
        overlayContainer.style.width = `${imgRect.width}px`;
        overlayContainer.style.height = `${imgRect.height}px`;
        // `pointer-events: none` on container, `all` on bboxes
        parentNode.appendChild(overlayContainer);

        // Bbox coordinates from API are relative to naturalWidth/Height (as per our prompt)
        // We need to scale them to the image's current display size (imgRect)
        const scaleX = imgRect.width / naturalWidth;
        const scaleY = imgRect.height / naturalHeight;

        translations.forEach(item => {
            if (!item.bbox || typeof item.bbox.x === 'undefined') {
                console.warn("Manga Translator: Invalid bbox data received:", item);
                return;
            }

            let { x, y, width, height } = item.bbox; // These are on natural image dimensions

            // Apply expansion (on natural dimensions, then scale)
            const exp = BBOX_EXPANSION_PIXELS;
            const expanded_x = Math.max(0, x - exp);
            const expanded_y = Math.max(0, y - exp);
            const expanded_width = Math.min(width + 2 * exp, naturalWidth - expanded_x);
            const expanded_height = Math.min(height + 2 * exp, naturalHeight - expanded_y);

            const translatedText = item.text || "";
            const bboxDiv = document.createElement('div');
            bboxDiv.className = `${SCRIPT_PREFIX}bbox`;

            // Scale expanded coordinates to the displayed image size
            bboxDiv.style.left = `${expanded_x * scaleX}px`;
            bboxDiv.style.top = `${expanded_y * scaleY}px`;
            bboxDiv.style.width = `${expanded_width * scaleX}px`;
            bboxDiv.style.height = `${expanded_height * scaleY}px`;
            bboxDiv.textContent = translatedText;

            bboxDiv.addEventListener('mousedown', onDragStart);

            const resizeHandle = document.createElement('div');
            resizeHandle.className = `${SCRIPT_PREFIX}resize_handle ${SCRIPT_PREFIX}resize_handle_br`;
            resizeHandle.addEventListener('mousedown', onResizeStart);
            bboxDiv.appendChild(resizeHandle);

            overlayContainer.appendChild(bboxDiv);
        });
    }

    async function handleTranslateClick(event) {
        event.stopPropagation();
        const icon = event.target;
        translateIconElement = icon; // Store for messaging
        const currentImgElement = activeImageTarget; // Use the one stored on mouseenter

        if (!currentImgElement || icon.classList.contains('processing')) return;

        const apiKey = getGeminiApiKey(icon);
        if (!apiKey) {
            // getGeminiApiKey already showed a message and might have prompted
            return;
        }

        const originalIconText = icon.dataset.originalText || icon.textContent;
        icon.dataset.originalText = originalIconText; // Store it if not already
        showTemporaryMessageOnIcon(icon, "Đang xử lý...", false, 120000); // Long message for processing
        icon.classList.add('processing');
        removeAllOverlays(currentImgElement); // Clear previous before starting

        try {
            const naturalWidth = currentImgElement.naturalWidth;
            const naturalHeight = currentImgElement.naturalHeight;
            if (naturalWidth === 0 || naturalHeight === 0) throw new Error("Kích thước ảnh gốc không hợp lệ.");

            // 1. Get original image data
            const { dataUrl: originalDataUrl, base64Content: originalBase64, mimeType: originalMimeType } = await getImageData(currentImgElement.src);

            // 2. Resize if needed
            const { base64Data: finalBase64ToSend, processedWidth, processedHeight } = await resizeImageIfNeeded(
                originalDataUrl, naturalWidth, naturalHeight,
                GEMINI_MAX_PROCESSING_DIMENSION, GEMINI_MAX_PROCESSING_DIMENSION,
                originalMimeType // Use original mime type for resizing target
            );
            // console.log(`Image to send: ${processedWidth}x${processedHeight}, Original: ${naturalWidth}x${naturalHeight}, Mime: ${originalMimeType}`);

            // 3. Call API
            // We still tell Gemini the *natural* dimensions for coordinate reference,
            // even if we sent a resized image for processing efficiency.
            const translations = await callGeminiApi(finalBase64ToSend, apiKey, originalMimeType, naturalWidth, naturalHeight);

            displayTranslations(currentImgElement, translations);
            if (translations && translations.length > 0) {
                showTemporaryMessageOnIcon(icon, "Đã dịch!", false, 3000);
                icon.classList.remove('processing'); icon.classList.add('success');
            } else {
                showTemporaryMessageOnIcon(icon, "Không thấy chữ!", false, 3000);
                 icon.classList.remove('processing');
            }

        } catch (error) {
            console.error("Manga Translator: Translation process failed:", error);
            showTemporaryMessageOnIcon(icon, `Lỗi: ${error.message.substring(0,100)}`, true, 6000);
        } finally {
            // Message handling is now part of showTemporaryMessageOnIcon timeout
            if (icon.classList.contains('processing') && !icon.classList.contains('success') && !icon.classList.contains('error')) {
                 icon.textContent = originalIconText;
                 icon.classList.remove('processing');
            }
        }
    }

    function addTranslateIcon(imgElement) {
        const parentNode = imgElement.parentNode;
        if (!parentNode) return null;

        // Remove existing icon first to prevent duplicates if mouse re-enters quickly
        removeTranslateIcon(imgElement, parentNode);

        if (getComputedStyle(parentNode).position === 'static') parentNode.style.position = 'relative';

        const icon = document.createElement('div');
        icon.textContent = 'Dịch';
        icon.className = `${SCRIPT_PREFIX}translate_icon`;
        icon.dataset.targetSrc = imgElement.src; // For removal
        icon.dataset.originalText = 'Dịch'; // Store original text for messages

        // Position relative to image's offset in parent
        icon.style.top = `${imgElement.offsetTop + 5}px`;
        icon.style.right = `${parentNode.offsetWidth - (imgElement.offsetLeft + imgElement.offsetWidth) + 5}px`;

        icon.addEventListener('click', handleTranslateClick);
        parentNode.appendChild(icon);
        return icon;
    }

    function removeTranslateIcon(imgElement, parentNodeOverride = null) {
        const parentNode = parentNodeOverride || imgElement.parentNode;
        if (!parentNode) return;
        const iconElement = parentNode.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${imgElement.src}"]`);
        if (iconElement) {
            iconElement.removeEventListener('click', handleTranslateClick);
            iconElement.remove();
        }
        if (translateIconElement === iconElement) translateIconElement = null;
    }

    function scanImages() {
        const images = document.querySelectorAll(`img:not([data-${SCRIPT_PREFIX}processed="true"])`);
        images.forEach(img => {
            if (!img.src || img.closest(`.${SCRIPT_PREFIX}bbox`)) { // Don't process images inside our bboxes
                img.dataset[`${SCRIPT_PREFIX}processed`] = 'true';
                return;
            }

            const processThisImage = () => {
                img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; // Mark as processed (attempted)
                const styles = getComputedStyle(img);
                if (styles.display === 'none' || styles.visibility === 'hidden' || img.offsetParent === null) return;

                if ((img.offsetWidth >= MIN_IMAGE_DIMENSION || img.offsetHeight >= MIN_IMAGE_DIMENSION) &&
                    (img.naturalWidth > 0 && img.naturalHeight > 0)) { // Check natural dims too

                    const parentContainer = img.parentNode; // Cache parent
                    if (!parentContainer) return;

                    img.addEventListener('mouseenter', () => {
                        activeImageTarget = img;
                        // Add icon only if no other icon for this image exists (robustness)
                        if (!parentContainer.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`)) {
                           addTranslateIcon(img);
                        }
                    });

                    // Handle mouse leaving the combined area of image and its parent (if icon is inside parent)
                    // This is tricky. A simpler approach: icon is child of parentNode.
                    // If mouse leaves parentNode AND is not over the icon itself, remove icon.
                    if (parentContainer) {
                        parentContainer.addEventListener('mouseleave', (event) => {
                            const iconExists = parentContainer.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`);
                            if (iconExists) {
                                const related = event.relatedTarget;
                                // If mouse is not moving to the image itself, or the icon, or any child of the parent that is not the image/icon
                                if (!parentContainer.contains(related) || (related !== img && related !== iconExists && !iconExists.contains(related) )) {
                                     // More aggressive: if relatedTarget is outside parent, remove.
                                     if (!parentContainer.contains(related)) {
                                        removeTranslateIcon(img, parentContainer);
                                        if (activeImageTarget === img) activeImageTarget = null;
                                     }
                                }
                            }
                        });
                         img.addEventListener('mouseleave', (event) => { // Also handle leaving the image itself
                            const iconExists = parentContainer.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`);
                            if (iconExists) {
                                const related = event.relatedTarget;
                                if (related !== iconExists && !iconExists.contains(related) && related !== parentContainer && !parentContainer.contains(related) ) {
                                    // If mouse moves to somewhere not the icon or its children, and not the parent
                                    // This logic can be complex. A simple timeout on mouseleave might be easier.
                                    // For now, if it's not the icon, and not within parent, try removing.
                                     if (!iconExists.contains(related) && !parentContainer.contains(related)) {
                                        setTimeout(() => { // Delay to allow moving to icon
                                            if (!iconExists.matches(':hover')) {
                                                removeTranslateIcon(img, parentContainer);
                                                if (activeImageTarget === img) activeImageTarget = null;
                                            }
                                        }, 100);
                                     }
                                }
                            }
                        });
                    }
                }
            };

            if (img.complete && img.naturalWidth > 0) {
                processThisImage();
            } else if (!img.complete) { // If not yet loaded
                img.addEventListener('load', processThisImage, { once: true });
                img.addEventListener('error', () => { img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; }, { once: true }); // Mark as processed on error too
            } else { // e.g. img.complete but naturalWidth is 0 (broken image)
                 img.dataset[`${SCRIPT_PREFIX}processed`] = 'true';
            }
        });
    }

    // --- Initialization and MutationObserver ---
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        scanImages();
    } else {
        document.addEventListener('DOMContentLoaded', scanImages, { once: true });
    }

    const observer = new MutationObserver((mutationsList) => {
        let needsScan = false;
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (node.tagName === 'IMG' || (node.querySelector && node.querySelector(`img:not([data-${SCRIPT_PREFIX}processed="true"])`))) {
                            needsScan = true;
                        }
                    }
                });
            } else if (mutation.type === 'attributes' && mutation.target.tagName === 'IMG' && mutation.attributeName === 'src') {
                mutation.target.removeAttribute(`data-${SCRIPT_PREFIX}processed`); // Re-process if src changes
                needsScan = true;
            }
        }
        if (needsScan) scanImages();
    });

    observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });

    console.log(`Manga Translator (Gemini) - Improved v${SCRIPT_VERSION} loaded.`);
})();