// ==UserScript== // @name Manga Translator (Gemini) - Contextual Manga Title // @namespace http://tampermonkey.net/ // @version 1.92.20250513 // @description Translate manga with Gemini, using detailed prompt, corrected coordinates, configurable model, and manga title context. // @author Your Name (Improved by AI) // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect generativelanguage.googleapis.com // @downloadURL https://update.greasyfork.icu/scripts/535861/Manga%20Translator%20%28Gemini%29%20-%20Contextual%20Manga%20Title.user.js // @updateURL https://update.greasyfork.icu/scripts/535861/Manga%20Translator%20%28Gemini%29%20-%20Contextual%20Manga%20Title.meta.js // ==/UserScript== (function() { 'use strict'; const SCRIPT_PREFIX = 'manga_translator_'; const SCRIPT_VERSION = '1.9.20250513'; // --- Configuration Constants --- const MIN_IMAGE_DIMENSION = 600; const GEMINI_API_KEY_STORAGE = SCRIPT_PREFIX + 'gemini_api_key'; const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash'; const GEMINI_MODEL_STORAGE_KEY = SCRIPT_PREFIX + 'gemini_model'; const DEFAULT_MANGA_TITLE = ''; // Tên truyện mặc định (để trống) const MANGA_TITLE_STORAGE_KEY = SCRIPT_PREFIX + 'manga_title'; // Key để lưu tên truyện const GEMINI_TARGET_PROCESSING_DIMENSION = 768; const IMAGE_RESIZE_QUALITY = 0.90; const BBOX_EXPANSION_PIXELS = 1; const ABSOLUTE_MIN_RESIZE_DIMENSION = 30; const BBOX_FONT_SIZE = '18px'; // --- Prompt Template --- // Đã thêm placeholder ${mangaTitle} 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. **Ngữ cảnh bổ sung:** * Tên truyện: \${mangaTitle} **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, phù hợp với ngữ cảnh hội thoại trong manga và tên truyện đã cung cấp. 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; let isDragging = false; let activeDraggableBox = null; let dragOffsetX = 0, dragOffsetY = 0; 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; } .${SCRIPT_PREFIX}translate_icon.success { background-color: #27ae60; } .${SCRIPT_PREFIX}overlay_container { position: absolute; pointer-events: none; overflow: hidden; z-index: 9999; } .${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; display: flex; align-items: center; justify-content: center; padding: 4px 6px; border-radius: 15%; box-shadow: 0 0 2px 1.5px white, 0 0 3px 3px white; 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: 20px; height: 20px; background-color: rgba(0,100,255,0.6); border: 1px solid rgba(255,255,255,0.8); border-radius: 3px; z-index: 100002; 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); 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); } 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.0-flash, gemini-2.5-flash-preview-04-17):', currentModel); if (newModel !== null) { 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; } function promptAndSetMangaTitle() { const currentTitle = GM_getValue(MANGA_TITLE_STORAGE_KEY, DEFAULT_MANGA_TITLE); const newTitle = prompt('Nhập tên truyện (để trống nếu không có):', currentTitle); if (newTitle !== null) { // Người dùng không nhấn Cancel GM_setValue(MANGA_TITLE_STORAGE_KEY, newTitle.trim()); const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div')); showTemporaryMessageOnIcon(effectiveIcon, `Đã đặt tên truyện: ${newTitle.trim() || "Không có"}`, false, 3000); if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon); } } GM_registerMenuCommand("Cài đặt/Cập nhật Tên Truyện", promptAndSetMangaTitle); function getMangaTitle() { return GM_getValue(MANGA_TITLE_STORAGE_KEY, DEFAULT_MANGA_TITLE) || DEFAULT_MANGA_TITLE; } // --- Drag and Resize Handlers (minified) --- 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()} 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(); const mangaTitleText = getMangaTitle().trim() || "Không được cung cấp"; // Lấy tên truyện, nếu trống thì dùng "Không được cung cấp" const promptText = GEMINI_PROMPT_TEMPLATE .replace(/\$\{imageProcessedWidth\}/g, imageProcessedWidth) .replace(/\$\{imageProcessedHeight\}/g, imageProcessedHeight) .replace(/\$\{mangaTitle\}/g, mangaTitleText); // Thay thế placeholder tên truyện 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, 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([]); } 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) {/* ignore */} 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."); 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 bbox data:", 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; const currentImgElement = activeImageTarget; if (!currentImgElement || icon.classList.contains('processing')) return; const apiKey = getGeminiApiKey(icon); if (!apiKey) return; const originalIconText = icon.dataset.originalText || icon.textContent; icon.dataset.originalText = originalIconText; showTemporaryMessageOnIcon(icon, "Đang xử lý...", false, 120000); 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'); } } } function addTranslateIcon(imgElement) { const parentNode = imgElement.parentNode; if (!parentNode) return null; 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; 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`)) { img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; return; } const processThisImg = () => { img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; 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; 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) { 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}"]`); if (event.relatedTarget !== iconExists && event.relatedTarget !== parent && (!iconExists || !iconExists.contains(event.relatedTarget))) { commonMouseLeaveHandler(event); } }); } }; if (img.complete && img.naturalWidth > 0) processThisImg(); else if (!img.complete) { img.addEventListener('load', processThisImg, { once: true }); img.addEventListener('error', () => { img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; }, { once: true }); } else img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'; }); } 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`); needsScan = true; } } if (needsScan) scanImages(); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); console.log(`Manga Translator (Gemini) - Contextual Manga Title v${SCRIPT_VERSION} loaded.`); })();