// ==UserScript== // @name Manga Translator (Gemini) - Configurable Model // @namespace http://tampermonkey.net/ // @version 1.8.20250513 // @description Translate manga with Gemini, using a detailed prompt, corrected coordinates, and configurable model. // @author Your Name (Improved by AI) // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect generativelanguage.googleapis.com // @downloadURL none // ==/UserScript== (function() { 'use strict'; const SCRIPT_PREFIX = 'manga_translator_'; const SCRIPT_VERSION = '1.8.20250513'; // --- Configuration Constants --- const MIN_IMAGE_DIMENSION = 600; // Kích thước tối thiểu của ảnh để xử lý const GEMINI_API_KEY_STORAGE = SCRIPT_PREFIX + 'gemini_api_key'; const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash-preview-04-17'; // Model Gemini mặc định const GEMINI_MODEL_STORAGE_KEY = SCRIPT_PREFIX + 'gemini_model'; // Key để lưu model người dùng chọn const GEMINI_TARGET_PROCESSING_DIMENSION = 1024; // Kích thước mục tiêu để xử lý ảnh trước khi gửi cho Gemini const IMAGE_RESIZE_QUALITY = 0.90; // Chất lượng ảnh khi resize (JPEG) const BBOX_EXPANSION_PIXELS = 5; // Số pixel mở rộng cho bounding box const ABSOLUTE_MIN_RESIZE_DIMENSION = 30; // Kích thước nhỏ nhất tuyệt đối cho bbox có thể resize (px) const BBOX_FONT_SIZE = '15px'; // Cỡ chữ mặc định cho văn bản dịch // --- Prompt Template --- const GEMINI_PROMPT_TEMPLATE = ` Bạn được cung cấp một hình ảnh manga có kích thước \${imageProcessedWidth}x\${imageProcessedHeight} pixel. **Nhiệm vụ:** 1. **Nhận diện Speech Bubbles:** Xác định vị trí của TẤT CẢ các "speech bubble" (bao gồm cả bong bóng thoại, suy nghĩ, v.v.) trong hình ảnh. 2. **Trích xuất Văn bản:** Đối với mỗi "speech bubble" đã nhận diện, trích xuất toàn bộ văn bản gốc bên trong. 3. **Dịch sang Tiếng Việt:** Dịch văn bản đã trích xuất sang tiếng Việt. Khi dịch, hãy cố gắng giữ phong cách tự nhiên và phù hợp với ngữ cảnh hội thoại trong manga. 4. **Xuất Dữ liệu:** Trả về kết quả dưới dạng một mảng JSON. Mỗi phần tử trong mảng là một đối tượng có cấu trúc sau: * \`"text"\`: (string) Văn bản đã được dịch sang tiếng Việt. * \`"bbox"\`: (object) Bounding box của "speech bubble". * \`"x"\`: (int) Tọa độ X của góc trên bên trái bounding box (tính bằng pixel). * \`"y"\`: (int) Tọa độ Y của góc trên bên trái bounding box (tính bằng pixel). * \`"width"\`: (int) Chiều rộng của bounding box (tính bằng pixel). * \`"height"\`: (int) Chiều cao của bounding box (tính bằng pixel). **Lưu ý về Bounding Box:** * Các tọa độ (\`x\`, \`y\`, \`width\`, \`height\`) phải là số nguyên. * Các tọa độ này phải là TƯƠNG ĐỐI so với kích thước của ảnh đã cung cấp (\${imageProcessedWidth}x\${imageProcessedHeight} pixel). **Trường hợp không có Speech Bubble:** Nếu không tìm thấy bất kỳ "speech bubble" nào trong ảnh, trả về một mảng JSON rỗng: \`[]\`. `; let activeImageTarget = null; let translateIconElement = null; // Lưu trữ icon dịch hiện tại để hiển thị thông báo // Biến cho kéo thả vị trí let isDragging = false; let activeDraggableBox = null; let dragOffsetX = 0, dragOffsetY = 0; // Biến cho resize kích thước let isResizing = false; let activeResizeBox = null; let activeResizeHandle = null; let initialResizeMouseX = 0, initialResizeMouseY = 0; let initialResizeBoxWidth = 0, initialResizeBoxHeight = 0; let minResizeWidth = 0, minResizeHeight = 0; let maxResizeWidth = 0, 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; 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; /* Cam cho đang xử lý/lỗi */ } .${SCRIPT_PREFIX}translate_icon.success { background-color: #27ae60; /* Xanh lá cho thành công */ } .${SCRIPT_PREFIX}overlay_container { position: absolute; pointer-events: none; /* Container không bắt sự kiện chuột */ overflow: hidden; z-index: 9999; /* Dưới 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; /* BBox phải tương tác được */ display: flex; align-items: center; justify-content: center; padding: 4px 6px; border-radius: 15%; /* Bo tròn vừa phải */ box-shadow: 0 0 2px 1.5px white, 0 0 4px 3px white; /* Viền kép để dễ nhìn */ line-height: 1.15; letter-spacing: -0.03em; cursor: grab; } .${SCRIPT_PREFIX}bbox_dragging { cursor: grabbing !important; opacity: 0.85; z-index: 100001 !important; 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: 3px; z-index: 100002; /* Trên 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); // --- Helper Functions for UX --- function showTemporaryMessageOnIcon(icon, message, isError = false, duration = 3500) { if (!icon) return; const originalText = icon.dataset.originalText || 'Dịch'; icon.textContent = message; icon.classList.remove('success', 'error', 'processing'); if (isError) icon.classList.add('error'); else icon.classList.add('processing'); setTimeout(() => { if (icon.textContent === message) { icon.textContent = originalText; icon.classList.remove('success', 'error', 'processing'); } }, duration); } // --- Configuration Functions --- function promptAndSetApiKey() { const currentKey = GM_getValue(GEMINI_API_KEY_STORAGE, ''); const apiKey = prompt('Vui lòng nhập Google AI Gemini API Key của bạn:', currentKey); if (apiKey !== null) { GM_setValue(GEMINI_API_KEY_STORAGE, apiKey); const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div')); if (apiKey) showTemporaryMessageOnIcon(effectiveIcon, "Đã lưu API Key!", false, 2000); else showTemporaryMessageOnIcon(effectiveIcon, "Đã xóa API Key!", false, 2000); if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon); } } 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) { const msgTarget = iconForMessages || document.body.appendChild(document.createElement('div')); showTemporaryMessageOnIcon(msgTarget, "Chưa có API Key! Mở menu script để cài đặt.", true, 5000); if (msgTarget.parentNode === document.body && !iconForMessages) document.body.removeChild(msgTarget); } return apiKey; } function promptAndSetGeminiModel() { const currentModel = GM_getValue(GEMINI_MODEL_STORAGE_KEY, DEFAULT_GEMINI_MODEL); const newModel = prompt('Nhập tên Model Gemini bạn muốn sử dụng (ví dụ: gemini-2.5-flash-preview-04-17, gemini-2.0-flash):', currentModel); if (newModel !== null) { // Người dùng không nhấn Cancel GM_setValue(GEMINI_MODEL_STORAGE_KEY, newModel.trim()); const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div')); showTemporaryMessageOnIcon(effectiveIcon, `Đã đặt Model: ${newModel.trim() || DEFAULT_GEMINI_MODEL}`, false, 3000); if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon); } } GM_registerMenuCommand("Cài đặt/Cập nhật Model Gemini", promptAndSetGeminiModel); function getGeminiModelName() { return GM_getValue(GEMINI_MODEL_STORAGE_KEY, DEFAULT_GEMINI_MODEL) || DEFAULT_GEMINI_MODEL; } // --- Drag and Resize Handlers (minified for brevity, assumed correct) --- function onDragStart(event){if(event.target.classList.contains(`${SCRIPT_PREFIX}resize_handle`)||event.button!==0)return;activeDraggableBox=this;isDragging=!0;const t=parseFloat(activeDraggableBox.style.left||0),e=parseFloat(activeDraggableBox.style.top||0);dragOffsetX=event.clientX-t;dragOffsetY=event.clientY-e;activeDraggableBox.classList.add(`${SCRIPT_PREFIX}bbox_dragging`);document.addEventListener("mousemove",onDragMove);document.addEventListener("mouseup",onDragEnd);document.addEventListener("mouseleave",onDocumentMouseLeave);event.preventDefault()}function onDragMove(event){if(!isDragging||!activeDraggableBox)return;event.preventDefault();const t=activeDraggableBox.parentNode;if(!t||!(t instanceof HTMLElement))return;let e=event.clientX-dragOffsetX,n=event.clientY-dragOffsetY;const o=t.offsetWidth-activeDraggableBox.offsetWidth,i=t.offsetHeight-activeDraggableBox.offsetHeight;e=Math.max(0,Math.min(e,o));n=Math.max(0,Math.min(n,i));activeDraggableBox.style.left=e+"px";activeDraggableBox.style.top=n+"px"}function onDragEnd(){if(!isDragging)return;isDragging=!1;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;isResizing=!0;initialResizeMouseX=event.clientX;initialResizeMouseY=event.clientY;initialResizeBoxWidth=activeResizeBox.offsetWidth;initialResizeBoxHeight=activeResizeBox.offsetHeight;minResizeWidth=initialResizeBoxWidth*.2;minResizeHeight=initialResizeBoxHeight*.2;maxResizeWidth=initialResizeBoxWidth*2.5;maxResizeHeight=initialResizeBoxHeight*2.5;activeResizeBox.classList.add(`${SCRIPT_PREFIX}bbox_dragging`);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 t=event.clientX-initialResizeMouseX,e=event.clientY-initialResizeMouseY;let n=initialResizeBoxWidth+t,o=initialResizeBoxHeight+e;n=Math.max(minResizeWidth,Math.min(n,maxResizeWidth));o=Math.max(minResizeHeight,Math.min(o,maxResizeHeight));const i=activeResizeBox.parentNode;if(i&&i instanceof HTMLElement){const s=parseFloat(activeResizeBox.style.left||0),a=parseFloat(activeResizeBox.style.top||0),r=i.offsetWidth-s,l=i.offsetHeight-a;n=Math.min(n,r);o=Math.min(o,l)}n=Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION,n);o=Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION,o);activeResizeBox.style.width=n+"px";activeResizeBox.style.height=o+"px"}function onResizeEnd(){if(!isResizing)return;isResizing=!1;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)}function onDocumentMouseLeave(event){if(isDragging)onDragEnd();if(isResizing)onResizeEnd()} // --- Core Logic Functions --- function removeAllOverlays(imgElement) { const parentNode = imgElement.parentNode; if (parentNode) { const existingContainers = parentNode.querySelectorAll(`.${SCRIPT_PREFIX}overlay_container[data-target-img-src="${imgElement.src}"]`); existingContainers.forEach(container => container.remove()); } } async function getImageData(imageUrl) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: imageUrl, responseType: 'blob', onload: (response) => { if (response.status >= 200 && response.status < 300) { const blob = response.response; const reader = new FileReader(); reader.onloadend = () => { const dataUrl = reader.result; resolve({ dataUrl, base64Content: dataUrl.split(',')[1], mimeType: blob.type || 'image/jpeg' }); }; reader.onerror = (err) => reject(new Error("FileReader error: " + err.toString())); reader.readAsDataURL(blob); } else reject(new Error(`Fetch failed: ${response.status} ${response.statusText}`)); }, onerror: (err) => reject(new Error(`GM_xhr error: ${err.statusText || 'Network error'}`)), ontimeout: () => reject(new Error("GM_xhr timeout fetching image.")) }); }); } async function preprocessImage(originalDataUrl, originalWidth, originalHeight, targetMaxDimension, targetMimeType) { return new Promise((resolve, reject) => { if (originalWidth <= targetMaxDimension && originalHeight <= targetMaxDimension) { resolve({ base64Data: originalDataUrl.split(',')[1], processedWidth: originalWidth, processedHeight: originalHeight, mimeTypeToUse: targetMimeType }); return; } const img = new Image(); img.onload = () => { let ratio = Math.min(targetMaxDimension / originalWidth, targetMaxDimension / 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, mimeTypeToUse: targetMimeType }); }; img.onerror = (err) => reject(new Error("Image load for resize failed: " + String(err))); img.src = originalDataUrl; }); } async function callGeminiApi(base64ImageData, apiKey, imageMimeType, imageProcessedWidth, imageProcessedHeight) { const modelName = getGeminiModelName(); // Lấy tên model đã cấu hình const promptText = GEMINI_PROMPT_TEMPLATE .replace(/\$\{imageProcessedWidth\}/g, imageProcessedWidth) .replace(/\$\{imageProcessedHeight\}/g, imageProcessedHeight); const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`; 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, // 90 giây timeout onload: (response) => { if (response.status >= 200 && response.status < 300) { try { const rd = JSON.parse(response.responseText); if (rd.candidates?.[0]?.content?.parts?.[0]?.text) { let rt = rd.candidates[0].content.parts[0].text.trim().replace(/^```json\s*/, '').replace(/\s*```$/, ''); resolve(JSON.parse(rt)); } else if (rd.promptFeedback?.blockReason) { reject(new Error(`API blocked: ${rd.promptFeedback.blockReason} - ${rd.promptFeedback.blockReasonMessage || 'No message.'}`)); } else resolve([]); // Không có candidate hoặc không có text } catch (e) { reject(new Error(`Parse Error: ${e.message}. Resp: ${response.responseText.substring(0,200)}...`)); } } else { let errorMsg = `API Error ${response.status}: ${response.statusText}`; try { errorMsg = `API Error ${response.status}: ${JSON.parse(response.responseText).error?.message || response.statusText}`; } catch (e) {/* Bỏ qua nếu không parse được JSON lỗi */} reject(new Error(errorMsg)); } }, onerror: (err) => reject(new Error(`Network/CORS Error: ${err.statusText || 'Unknown'}`)), ontimeout: () => reject(new Error("Gemini API timed out.")) }); }); } function displayTranslations(imgElement, translations, processedWidth, processedHeight) { removeAllOverlays(imgElement); if (!translations || translations.length === 0) return; const parentNode = imgElement.parentNode; if (!parentNode) return; if (getComputedStyle(parentNode).position === 'static') parentNode.style.position = 'relative'; const imgRect = imgElement.getBoundingClientRect(); if (processedWidth === 0 || processedHeight === 0) { console.error("Manga Translator: Processed dimensions are zero. Cannot display translations accurately."); return; } const overlayContainer = document.createElement('div'); overlayContainer.className = `${SCRIPT_PREFIX}overlay_container`; overlayContainer.dataset.targetImgSrc = imgElement.src; overlayContainer.style.top = `${imgElement.offsetTop}px`; overlayContainer.style.left = `${imgElement.offsetLeft}px`; overlayContainer.style.width = `${imgRect.width}px`; overlayContainer.style.height = `${imgRect.height}px`; parentNode.appendChild(overlayContainer); const scaleX = imgRect.width / processedWidth; const scaleY = imgRect.height / processedHeight; translations.forEach(item => { if (!item.bbox || typeof item.bbox.x !== 'number' || typeof item.bbox.y !== 'number' || typeof item.bbox.width !== 'number' || typeof item.bbox.height !== 'number') { console.warn("Manga Translator: Invalid or incomplete bbox data received:", item); return; } let { x, y, width, height } = item.bbox; const exp = BBOX_EXPANSION_PIXELS; let expanded_x = Math.max(0, x - exp); let expanded_y = Math.max(0, y - exp); let expanded_width = Math.min(width + 2 * exp, processedWidth - expanded_x); let expanded_height = Math.min(height + 2 * exp, processedHeight - expanded_y); const bboxDiv = document.createElement('div'); bboxDiv.className = `${SCRIPT_PREFIX}bbox`; 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 = item.text || ""; 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; // Lưu icon để hiển thị thông báo const currentImgElement = activeImageTarget; if (!currentImgElement || icon.classList.contains('processing')) return; const apiKey = getGeminiApiKey(icon); if (!apiKey) return; // getGeminiApiKey đã hiển thị thông báo const originalIconText = icon.dataset.originalText || icon.textContent; icon.dataset.originalText = originalIconText; showTemporaryMessageOnIcon(icon, "Đang xử lý...", false, 120000); // Timeout dài cho xử lý icon.classList.add('processing'); removeAllOverlays(currentImgElement); try { const naturalWidth = currentImgElement.naturalWidth; const naturalHeight = currentImgElement.naturalHeight; if (naturalWidth === 0 || naturalHeight === 0) throw new Error("Ảnh gốc không hợp lệ."); const { dataUrl: originalDataUrl, mimeType: originalMimeType } = await getImageData(currentImgElement.src); const { base64Data: finalBase64ToSend, processedWidth, processedHeight, mimeTypeToUse } = await preprocessImage( originalDataUrl, naturalWidth, naturalHeight, GEMINI_TARGET_PROCESSING_DIMENSION, originalMimeType ); const translations = await callGeminiApi(finalBase64ToSend, apiKey, mimeTypeToUse, processedWidth, processedHeight); displayTranslations(currentImgElement, translations, processedWidth, processedHeight); if (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 failed:", error); showTemporaryMessageOnIcon(icon, `Lỗi: ${error.message.substring(0,100)}...`, true, 7000); icon.classList.remove('processing', 'success'); icon.classList.add('error'); } finally { if (icon.classList.contains('processing') && !icon.classList.contains('success') && !icon.classList.contains('error')) { icon.textContent = originalIconText; icon.classList.remove('processing'); } } } // --- Image Scanning and Icon Management --- function addTranslateIcon(imgElement) { const parentNode = imgElement.parentNode; if (!parentNode) return null; removeTranslateIcon(imgElement, parentNode); // Xóa icon cũ nếu có 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; icon.dataset.originalText = 'Dịch'; 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 iconEl = parentNode.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${imgElement.src}"]`); if (iconEl) { iconEl.removeEventListener('click', handleTranslateClick); iconEl.remove(); } if (translateIconElement === iconEl) 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`)) { // Bỏ qua ảnh không có src hoặc là ảnh trong bbox img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; return; } const processThisImg = () => { img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; // Đánh dấu đã xử lý (hoặc cố gắng xử lý) 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) { const parent = img.parentNode; if (!parent) return; img.addEventListener('mouseenter', () => { activeImageTarget = img; // Chỉ thêm icon nếu chưa có cho ảnh này if (!parent.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`)) { addTranslateIcon(img); } }); let leaveTimeout; const commonMouseLeaveHandler = (event) => { clearTimeout(leaveTimeout); leaveTimeout = setTimeout(() => { const iconExists = parent.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`); const isMouseOverImg = img.matches(':hover'); const isMouseOverIcon = iconExists ? iconExists.matches(':hover') : false; if (iconExists && !isMouseOverImg && !isMouseOverIcon) { // Chỉ xóa nếu chuột không trỏ vào parent (hoặc một phần tử con không liên quan) if (!parent.contains(event.relatedTarget) || (event.relatedTarget !== img && event.relatedTarget !== iconExists && !iconExists.contains(event.relatedTarget))) { removeTranslateIcon(img, parent); if (activeImageTarget === img) activeImageTarget = null; } } }, 150); }; parent.addEventListener('mouseleave', commonMouseLeaveHandler); img.addEventListener('mouseleave', (event) => { const iconExists = parent.querySelector(`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`); // Nếu chuột rời ảnh và không đi vào icon hoặc parent, thì mới xử lý if (event.relatedTarget !== iconExists && event.relatedTarget !== parent && (!iconExists || !iconExists.contains(event.relatedTarget))) { commonMouseLeaveHandler(event); } }); } }; if (img.complete && img.naturalWidth > 0) processThisImg(); // Ảnh đã load xong else if (!img.complete) { // Ảnh chưa load img.addEventListener('load', processThisImg, { once: true }); img.addEventListener('error', () => { img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; }, { once: true }); // Đánh dấu lỗi cũng là đã xử lý } else img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; // Ảnh complete nhưng naturalWidth/Height = 0 (ảnh hỏng) }); } // --- 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 m of mutationsList) { if (m.type === 'childList' && m.addedNodes.length > 0) { m.addedNodes.forEach(n => { if (n.nodeType === Node.ELEMENT_NODE && (n.tagName === 'IMG' || (n.querySelector?.(`img:not([data-${SCRIPT_PREFIX}processed="true"])`)))) needsScan = true; }); } else if (m.type === 'attributes' && m.target.tagName === 'IMG' && m.attributeName === 'src') { m.target.removeAttribute(`data-${SCRIPT_PREFIX}processed`); // Xử lý lại nếu src thay đổi needsScan = true; } } if (needsScan) scanImages(); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); console.log(`Manga Translator (Gemini) - Configurable Model v${SCRIPT_VERSION} loaded.`); })();