Greasy Fork is available in English.
Translate manga with Gemini, using detailed prompt, corrected coordinates, configurable model, and manga title context. (DEBUG VERSION)
当前为
// ==UserScript==
// @name Manga Translator (Gemini) - Contextual Manga Title
// @namespace http://tampermonkey.net/
// @version 1.92.20250514
// @description Translate manga with Gemini, using detailed prompt, corrected coordinates, configurable model, and manga title context. (DEBUG VERSION)
// @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.9.20250513_DEBUG' // Added _DEBUG to version
// --- 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' // Changed from 1.5 to 2.0 for testing new models
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.9
const BBOX_EXPANSION_PIXELS = 0
const ABSOLUTE_MIN_RESIZE_DIMENSION = 30
const BBOX_FONT_SIZE = '14px'
// --- 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.
**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í và chiều rộng chiều cao chính xác 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: 1px 1px; 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') // Changed: always show processing if not error, success will be set later
setTimeout(() => {
if (icon.textContent === message) { // Check if message is still the same
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):', // Updated example
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 * 0.2
minResizeHeight = initialResizeBoxHeight * 0.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) => {
console.log(
`[DEBUG] preprocessImage: original dimensions = ${originalWidth}x${originalHeight}, targetMaxDimension = ${targetMaxDimension}`
)
if (
originalWidth <= targetMaxDimension &&
originalHeight <= targetMaxDimension
) {
console.log(
'[DEBUG] preprocessImage: Image is already within target dimensions. Using original.'
)
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)
console.log(
`[DEBUG] preprocessImage: Resized to ${resizedWidth}x${resizedHeight}`
)
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'
console.log(
`[DEBUG] callGeminiApi: Using model = ${modelName}, imageProcessedWidth = ${imageProcessedWidth}, imageProcessedHeight = ${imageProcessedHeight}, mangaTitle = "${mangaTitleText}"`
)
const promptText = GEMINI_PROMPT_TEMPLATE.replace(
/\$\{imageProcessedWidth\}/g,
imageProcessedWidth
)
.replace(/\$\{imageProcessedHeight\}/g, imageProcessedHeight)
.replace(/\$\{mangaTitle\}/g, mangaTitleText)
// console.log("[DEBUG] callGeminiApi: Generated Prompt:", promptText); // Uncomment if you need to see the full prompt
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, // Increased timeout
onload: (response) => {
console.log(
'[DEBUG] callGeminiApi: Raw Gemini Response Status:',
response.status
)
console.log(
'[DEBUG] callGeminiApi: Raw Gemini Response Text (first 500 chars):',
response.responseText.substring(0, 500)
)
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*```$/, '')
console.log(
'[DEBUG] callGeminiApi: Parsed and cleaned Gemini text result:',
rt
)
resolve(JSON.parse(rt))
} else if (rd.promptFeedback?.blockReason) {
console.error(
'[DEBUG] callGeminiApi: API blocked response:',
rd.promptFeedback
)
reject(
new Error(
`API blocked: ${rd.promptFeedback.blockReason} - ${
rd.promptFeedback.blockReasonMessage || 'No message.'
}`
)
)
} else {
console.warn(
'[DEBUG] callGeminiApi: No text found in Gemini response, resolving with empty array. Full response:',
rd
)
resolve([])
}
} catch (e) {
console.error(
'[DEBUG] callGeminiApi: Error parsing Gemini response:',
e
)
reject(
new Error(
`Parse Error: ${
e.message
}. Resp: ${response.responseText.substring(0, 200)}...`
)
)
}
} else {
let errorMsg = `API Error ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(response.responseText)
errorMsg = `API Error ${response.status}: ${
errorJson.error?.message || response.statusText
}`
console.error(
'[DEBUG] callGeminiApi: API Error JSON:',
errorJson
)
} catch (e) {
/* ignore parsing error of error message */
}
console.error('[DEBUG] callGeminiApi: API Error:', errorMsg)
reject(new Error(errorMsg))
}
},
onerror: (err) => {
console.error('[DEBUG] callGeminiApi: Network/CORS Error:', err)
reject(
new Error(`Network/CORS Error: ${err.statusText || 'Unknown'}`)
)
},
ontimeout: () => {
console.error('[DEBUG] callGeminiApi: Timeout.')
reject(new Error('Gemini API timed out.'))
},
})
})
}
function displayTranslations(
imgElement,
translations,
processedWidth,
processedHeight
) {
removeAllOverlays(imgElement)
if (!translations || translations.length === 0) {
console.log('[DEBUG] displayTranslations: No translations to display.')
return
}
const parentNode = imgElement.parentNode
if (!parentNode) {
console.error(
'[DEBUG] displayTranslations: Image parent node not found.'
)
return
}
if (getComputedStyle(parentNode).position === 'static')
parentNode.style.position = 'relative'
const imgRect = imgElement.getBoundingClientRect()
if (processedWidth === 0 || processedHeight === 0) {
console.error(
'[DEBUG] displayTranslations: Processed dimensions are zero. This should not happen.'
)
return
}
console.log(
`[DEBUG] displayTranslations: imgRect = ${imgRect.width}x${imgRect.height}, processed = ${processedWidth}x${processedHeight}`
)
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
console.log(`[DEBUG] displayTranslations: scaleX = ${scaleX}, scaleY = ${scaleY}`)
translations.forEach((item, index) => {
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(
`[DEBUG] displayTranslations: Invalid bbox data for item ${index}:`,
item
)
return
}
let { x, y, width, height } = item.bbox
console.log(
`[DEBUG] displayTranslations: Item ${index} - Original bbox from Gemini: x=${x}, y=${y}, w=${width}, h=${height}`
)
const exp = BBOX_EXPANSION_PIXELS
let expanded_x_proc = Math.max(0, x - exp) // in processed space
let expanded_y_proc = Math.max(0, y - exp) // in processed space
let expanded_width_proc = Math.min(
width + 2 * exp,
processedWidth - expanded_x_proc
)
let expanded_height_proc = Math.min(
height + 2 * exp,
processedHeight - expanded_y_proc
)
const final_x_display = expanded_x_proc * scaleX
const final_y_display = expanded_y_proc * scaleY
const final_width_display = expanded_width_proc * scaleX
const final_height_display = expanded_height_proc * scaleY
console.log(
`[DEBUG] displayTranslations: Item ${index} - Calculated display coords: x=${final_x_display.toFixed(
2
)}, y=${final_y_display.toFixed(2)}, w=${final_width_display.toFixed(
2
)}, h=${final_height_display.toFixed(2)}`
)
const bboxDiv = document.createElement('div')
bboxDiv.className = `${SCRIPT_PREFIX}bbox`
bboxDiv.style.left = `${final_x_display}px`
bboxDiv.style.top = `${final_y_display}px`
bboxDiv.style.width = `${final_width_display}px`
bboxDiv.style.height = `${final_height_display}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) // Long timeout for message
icon.classList.remove('success', 'error') // Clear previous status
icon.classList.add('processing')
removeAllOverlays(currentImgElement)
try {
const naturalWidth = currentImgElement.naturalWidth
const naturalHeight = currentImgElement.naturalHeight
console.log(
`[DEBUG] handleTranslateClick: Image natural dimensions = ${naturalWidth}x${naturalHeight}`
)
if (naturalWidth === 0 || naturalHeight === 0)
throw new Error('Ảnh gốc không hợp lệ (0x0).')
const { dataUrl: originalDataUrl, mimeType: originalMimeType } =
await getImageData(currentImgElement.src)
console.log(
`[DEBUG] handleTranslateClick: Got image data, mimeType = ${originalMimeType}`
)
const {
base64Data: finalBase64ToSend,
processedWidth,
processedHeight,
mimeTypeToUse,
} = await preprocessImage(
originalDataUrl,
naturalWidth,
naturalHeight,
GEMINI_TARGET_PROCESSING_DIMENSION,
originalMimeType
)
console.log(
`[DEBUG] handleTranslateClick: Image preprocessed. Sent to Gemini as ${processedWidth}x${processedHeight}, mimeType = ${mimeTypeToUse}`
)
const translations = await callGeminiApi(
finalBase64ToSend,
apiKey,
mimeTypeToUse,
processedWidth,
processedHeight
)
console.log(
'[DEBUG] handleTranslateClick: Received translations from Gemini:',
translations
)
displayTranslations(
currentImgElement,
translations,
processedWidth,
processedHeight
)
if (translations?.length > 0) {
showTemporaryMessageOnIcon(icon, 'Đã dịch!', false, 3000)
icon.classList.remove('processing', 'error')
icon.classList.add('success')
} else {
showTemporaryMessageOnIcon(icon, 'Không thấy chữ!', false, 3000)
icon.classList.remove('processing', 'success', 'error') // Clear all status
}
} 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 {
// Ensure icon state is reset if it's still 'processing' but not explicitly success/error
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) // Ensure no duplicates
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' // Store original text for reset
// Position calculation adjusted for robustness
const imgRect = imgElement.getBoundingClientRect()
const parentRect = parentNode.getBoundingClientRect()
icon.style.top = `${imgElement.offsetTop + 5}px`
icon.style.right = `${
parentNode.offsetWidth -
(imgElement.offsetLeft + imgElement.offsetWidth) +
5
}px`
// Fallback positioning if offsetParent is weird or img is deeply nested
if (
imgElement.offsetTop === 0 &&
imgElement.offsetLeft === 0 &&
imgRect.top > parentRect.top
) {
icon.style.top = `${imgRect.top - parentRect.top + 5}px`
icon.style.right = `${
parentRect.right - imgRect.right + 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' // Mark as processed
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 && // Ensure natural dimensions are loaded
img.naturalHeight > 0
) {
const parent = img.parentNode
if (!parent) return
img.addEventListener('mouseenter', () => {
activeImageTarget = img
// Check if icon already exists for this specific img src *within this parent*
if (
!parent.querySelector(
`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
)
) {
translateIconElement = addTranslateIcon(img) // Store the created icon
} else {
// If icon exists, ensure translateIconElement points to it if it's the current one
const existingIcon = parent.querySelector(
`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
);
if (existingIcon) translateIconElement = existingIcon;
}
})
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) {
// More robust check for relatedTarget
let related = event.relatedTarget
let shouldRemove = true
while(related && related !== parent) {
if (related === img || related === iconExists) {
shouldRemove = false;
break;
}
related = related.parentNode;
}
if (related === parent && (event.target === img || event.target === iconExists)) {
// If mouse moved from img/icon directly to parent, don't remove immediately
// This case is tricky, might need further refinement if issues persist
} else if (shouldRemove) {
removeTranslateIcon(img, parent)
if (activeImageTarget === img) activeImageTarget = null
}
}
}, 150) // Slightly increased delay
}
// Add mouseleave to parent as well for better icon removal
parent.addEventListener('mouseleave', commonMouseLeaveHandler)
img.addEventListener('mouseleave', (event) => {
const iconExists = parent.querySelector(
`.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
)
// If mouse leaves img to something that is not the icon or the parent containing the icon
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' // Mark as processed on error too
},
{ once: true }
)
} else {
// If complete but naturalWidth is 0 (e.g. broken image link that reported complete)
img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'
}
})
}
// Initial scan
if (
document.readyState === 'complete' ||
document.readyState === 'interactive'
)
scanImages()
else document.addEventListener('DOMContentLoaded', scanImages, { once: true })
// Observe DOM changes
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`) // Re-process if src changes
needsScan = true
}
}
if (needsScan) scanImages()
})
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src'], // Only observe src attribute changes for IMG tags
})
console.log(
`Manga Translator (Gemini) - Contextual Manga Title v${SCRIPT_VERSION} loaded. Open console for DEBUG logs.`
)
})()