Translate manga with Gemini, using detailed English prompt, configurable model, target language, manga title context, adjustable text box style (%), per-box font size controls, and configurable default font size. Status icon fix.
// ==UserScript==
// @name Manga Translator (Gemini) - Contextual Manga Title
// @namespace http://tampermonkey.net/
// @version 2.03.20250528_STATUS_ICON_FIX
// @description Translate manga with Gemini, using detailed English prompt, configurable model, target language, manga title context, adjustable text box style (%), per-box font size controls, and configurable default font size. Status icon fix.
// @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 = '2.03.20250528_STATUS_ICON_FIX'
// --- Configuration Constants ---
const MIN_IMAGE_DIMENSION = 600
const GEMINI_API_KEY_STORAGE = SCRIPT_PREFIX + 'gemini_api_key'
const DEFAULT_GEMINI_MODEL = 'gemini-1.5-flash-latest'
const GEMINI_MODEL_STORAGE_KEY = SCRIPT_PREFIX + 'gemini_model'
const DEFAULT_MANGA_TITLE = ''
const MANGA_TITLE_STORAGE_KEY = SCRIPT_PREFIX + 'manga_title'
const TARGET_LANGUAGE_STORAGE_KEY = SCRIPT_PREFIX + 'target_language';
const DEFAULT_TARGET_LANGUAGE_CODE = 'en';
const AVAILABLE_LANGUAGES = {
'en': 'English', 'vi': 'Vietnamese', 'ja': '日本語 (Japanese)', 'ko': '한국어 (Korean)',
'zh-CN': '中文 (简体 - Simplified Chinese)', 'zh-TW': '中文 (繁體 - Traditional Chinese)',
'fr': 'Français (French)', 'de': 'Deutsch (German)', 'es': 'Español (Spanish)', 'ru': 'Русский (Russian)',
};
const DEFAULT_TARGET_LANGUAGE_NAME = AVAILABLE_LANGUAGES[DEFAULT_TARGET_LANGUAGE_CODE];
const GEMINI_TARGET_PROCESSING_DIMENSION = 768
const IMAGE_RESIZE_QUALITY = 0.9
const ABSOLUTE_MIN_RESIZE_DIMENSION = 30
const BORDER_RADIUS_STORAGE_KEY = SCRIPT_PREFIX + 'bbox_border_radius';
const DEFAULT_BORDER_RADIUS = '15%';
const DEFAULT_INITIAL_FONT_SIZE_STORAGE_KEY = SCRIPT_PREFIX + 'default_initial_font_size';
const DEFAULT_INITIAL_FONT_SIZE_VALUE = '16px';
const FONT_SIZE_ADJUST_STEP = 1;
const LOCAL_MIN_FONT_SIZE_PX = 8;
const LOCAL_MAX_FONT_SIZE_PX = 32;
// --- Prompt Template (English) ---
const GEMINI_PROMPT_TEMPLATE = `
You are provided with a manga image of size \${imageProcessedWidth}x\${imageProcessedHeight} pixels.
**Additional Context:**
* Manga Title: \${mangaTitle}
* Target Language for Translation: \${targetLanguageName}
**Task:**
1. **Identify Speech Bubbles:** Accurately locate all "speech bubbles" (including dialogue, thought bubbles, etc.) in the image.
2. **Extract Text:** For each identified speech bubble, extract all the original text within it.
3. **Translate to \${targetLanguageName}:** Translate the extracted text into \${targetLanguageName}. Aim for a natural style appropriate for manga dialogue, considering the manga title and target language.
4. **Output Data:** Return the result as a JSON array. Each element in the array is an object with the following structure:
* \`"text"\`: (string) Text translated into \${targetLanguageName}.
* \`"bbox"\`: (object) Bounding box of the "speech bubble", with values as **floats from 0.0 to 1.0**, representing percentages of the provided image's full dimensions.
* \`"x_ratio"\`: (float) X-coordinate of the top-left corner, as a ratio of the image width (e.g., 0.05 means 5% from the left edge).
* \`"y_ratio"\`: (float) Y-coordinate of the top-left corner, as a ratio of the image height (e.g., 0.10 means 10% from the top edge).
* \`"width_ratio"\`: (float) Width of the bounding box, as a ratio of the image width (e.g., 0.25 means 25% of image width).
* \`"height_ratio"\`: (float) Height of the bounding box, as a ratio of the image height (e.g., 0.15 means 15% of image height).
**Bounding Box Notes (Important):**
* The values within \`"bbox"\` (\`"x_ratio"\`, \`"y_ratio"\`, \`"width_ratio"\`, \`"height_ratio"\`) **MUST BE FLOATS** between 0.0 and 1.0.
* These ratios must correspond to the dimensions of the provided image (\${imageProcessedWidth}x\${imageProcessedHeight} pixels).
**Case: No Speech Bubbles Found:**
If no speech bubbles are found in the image, return an empty JSON array: \`[]\`.
`
let activeImageTarget = null
let translateIconElement = null
let isDragging = false, activeDraggableBox = null, dragOffsetX = 0, dragOffsetY = 0;
let isResizing = false, activeResizeBox = null, activeResizeHandle = null;
let initialResizeMouseX = 0, initialResizeMouseY = 0, initialResizeBoxWidth = 0, initialResizeBoxHeight = 0;
let minResizeWidth = 0, minResizeHeight = 0, maxResizeWidth = 0, maxResizeHeight = 0;
let isAdjustingBorderRadius = false, activeBorderRadiusBoxBg = null;
let initialBorderRadiusMouseX = 0, initialBorderRadiusValue = 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; box-sizing: border-box; pointer-events: all !important; cursor: grab;
}
.${SCRIPT_PREFIX}text_actual_bg {
position: absolute; inset: 0; background: white;
box-shadow: 0 0 2px 1.5px white, 0 0 3px 3px white;
border-radius: ${DEFAULT_BORDER_RADIUS};
z-index: 1; pointer-events: none;
}
.${SCRIPT_PREFIX}text_display {
position: relative; z-index: 2; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-family: "Patrick Hand", "Comic Neue", cursive, sans-serif;
font-size: var(--current-font-size, ${DEFAULT_INITIAL_FONT_SIZE_VALUE});
font-weight: 400; text-align: center; color: black;
overflow: hidden; padding: 2px 4px; box-sizing: border-box;
line-height: 1.15; letter-spacing: -0.03em; pointer-events: none;
}
.${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: 3;
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; }
.${SCRIPT_PREFIX}border_radius_handle {
position: absolute; width: 16px; height: 16px; background-color: rgba(255, 80, 80, 0.7);
border: 1px solid rgba(255,255,255,0.9); border-radius: 50%;
cursor: ew-resize; z-index: 3; top: -2px; left: -2px;
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}border_radius_handle, .${SCRIPT_PREFIX}border_radius_handle_active {
opacity: 1; visibility: visible;
}
.${SCRIPT_PREFIX}font_size_button {
position: absolute;
width: 12px; height: 12px;
background-color: rgba(100, 100, 255, 0.7);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 2px;
color: white; font-size: 10px; font-weight: bold;
line-height: 10px; text-align: center;
cursor: pointer; z-index: 3; pointer-events: all;
opacity: 0; visibility: hidden;
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
user-select: none;
}
.${SCRIPT_PREFIX}bbox:hover .${SCRIPT_PREFIX}font_size_button {
opacity: 1; visibility: visible;
}
.${SCRIPT_PREFIX}font_size_minus_button { top: -2px; left: 20px; }
.${SCRIPT_PREFIX}font_size_plus_button { top: -2px; left: 35px; }
`
document.head.appendChild(style)
function showTemporaryMessageOnIcon(icon, message, isError = false, duration = 3500) {
if (!icon) return
const originalText = icon.dataset.originalText || 'Translate'
icon.textContent = message
icon.classList.remove('success', 'error', 'processing')
if (isError) icon.classList.add('error')
else icon.classList.add('processing') // Keep .processing class if it's a status message
setTimeout(() => {
if (icon.textContent === message) { // Only revert if message hasn't changed
icon.textContent = originalText
icon.classList.remove('success', 'error', 'processing') // Clear all status if reverting to original
}
}, duration)
}
// --- Settings Functions ---
function promptAndSetApiKey() {
const currentKey = GM_getValue(GEMINI_API_KEY_STORAGE, '')
const apiKey = prompt('Please enter your Google AI Gemini API Key:', currentKey)
if (apiKey !== null) {
GM_setValue(GEMINI_API_KEY_STORAGE, apiKey)
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'))
showTemporaryMessageOnIcon(effectiveIcon, apiKey ? 'API Key saved!' : 'API Key cleared!', false, 2000)
if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon)
}
}
GM_registerMenuCommand('Set/Update Gemini API Key', 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, 'API Key not set! Open script menu to set it.', 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(`Enter Gemini Model name (e.g., ${DEFAULT_GEMINI_MODEL}):`, currentModel)
if (newModel !== null) {
GM_setValue(GEMINI_MODEL_STORAGE_KEY, newModel.trim())
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'))
showTemporaryMessageOnIcon(effectiveIcon, `Model set to: ${newModel.trim() || DEFAULT_GEMINI_MODEL}`, false, 3000)
if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon)
}
}
GM_registerMenuCommand('Set/Update Gemini Model', 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('Enter manga title (leave blank if none):', currentTitle)
if (newTitle !== null) {
GM_setValue(MANGA_TITLE_STORAGE_KEY, newTitle.trim())
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'))
showTemporaryMessageOnIcon(effectiveIcon, `Manga title set to: ${newTitle.trim() || 'None'}`, false, 3000)
if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon)
}
}
GM_registerMenuCommand('Set/Update Manga Title', promptAndSetMangaTitle)
function getMangaTitle() {
return GM_getValue(MANGA_TITLE_STORAGE_KEY, DEFAULT_MANGA_TITLE) || DEFAULT_MANGA_TITLE
}
function getTargetLanguageCode() {
return GM_getValue(TARGET_LANGUAGE_STORAGE_KEY, DEFAULT_TARGET_LANGUAGE_CODE);
}
function getTargetLanguageName() {
const code = getTargetLanguageCode();
return AVAILABLE_LANGUAGES[code] || DEFAULT_TARGET_LANGUAGE_NAME;
}
function promptAndSetTargetLanguage() {
const currentLangCode = getTargetLanguageCode();
const currentLangName = AVAILABLE_LANGUAGES[currentLangCode] || 'Unknown';
let promptMessage = 'Select target language (Enter code):\n';
for (const code in AVAILABLE_LANGUAGES) { promptMessage += `\n- ${AVAILABLE_LANGUAGES[code]}: "${code}"`; }
promptMessage += `\n\nCurrent language: ${currentLangName} (${currentLangCode}).`;
const newLangCodeInput = prompt(promptMessage, currentLangCode);
if (newLangCodeInput !== null) {
const normalizedNewLangCode = newLangCodeInput.trim().toLowerCase();
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'));
if (AVAILABLE_LANGUAGES[normalizedNewLangCode]) {
GM_setValue(TARGET_LANGUAGE_STORAGE_KEY, normalizedNewLangCode);
showTemporaryMessageOnIcon(effectiveIcon, `Language changed to: ${AVAILABLE_LANGUAGES[normalizedNewLangCode]}`, false, 3000);
} else if (newLangCodeInput.trim() === "" && AVAILABLE_LANGUAGES[DEFAULT_TARGET_LANGUAGE_CODE]) {
GM_setValue(TARGET_LANGUAGE_STORAGE_KEY, DEFAULT_TARGET_LANGUAGE_CODE);
showTemporaryMessageOnIcon(effectiveIcon, `Language reset to: ${DEFAULT_TARGET_LANGUAGE_NAME}`, false, 3000);
} else {
showTemporaryMessageOnIcon(effectiveIcon, `Invalid language code "${newLangCodeInput.trim()}".`, true, 3000);
}
if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon);
}
}
GM_registerMenuCommand('Select Target Language', promptAndSetTargetLanguage);
function resetBorderRadiusToDefault() {
GM_setValue(BORDER_RADIUS_STORAGE_KEY, DEFAULT_BORDER_RADIUS);
const allTextBgs = document.querySelectorAll(`.${SCRIPT_PREFIX}text_actual_bg`);
allTextBgs.forEach(bg => { bg.style.borderRadius = DEFAULT_BORDER_RADIUS; });
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'));
showTemporaryMessageOnIcon(effectiveIcon, `Border radius reset to ${DEFAULT_BORDER_RADIUS} and applied.`, false, 3000);
if (effectiveIcon.parentNode === document.body && !translateIconElement) document.body.removeChild(effectiveIcon);
}
GM_registerMenuCommand('Reset Border Radius', resetBorderRadiusToDefault);
function getConfiguredInitialFontSize() {
return GM_getValue(DEFAULT_INITIAL_FONT_SIZE_STORAGE_KEY, DEFAULT_INITIAL_FONT_SIZE_VALUE);
}
function promptAndSetDefaultInitialFontSize() {
const currentDefault = getConfiguredInitialFontSize();
const newDefaultInput = prompt(
`Enter the default font size for new translation boxes (in pixels, e.g., 14, 16, 18).\nMin: ${LOCAL_MIN_FONT_SIZE_PX}px, Max: ${LOCAL_MAX_FONT_SIZE_PX}px.\nCurrent default: ${currentDefault}`,
parseFloat(currentDefault)
);
if (newDefaultInput !== null) {
let newDefaultPx = parseFloat(newDefaultInput);
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'));
if (isNaN(newDefaultPx)) {
showTemporaryMessageOnIcon(effectiveIcon, `Invalid input. Please enter a number.`, true, 3000);
} else {
newDefaultPx = Math.max(LOCAL_MIN_FONT_SIZE_PX, Math.min(newDefaultPx, LOCAL_MAX_FONT_SIZE_PX));
const newDefaultString = `${newDefaultPx}px`;
GM_setValue(DEFAULT_INITIAL_FONT_SIZE_STORAGE_KEY, newDefaultString);
showTemporaryMessageOnIcon(effectiveIcon, `Default font size set to: ${newDefaultString}. New boxes will use this.`, false, 3500);
}
if (effectiveIcon.parentNode === document.body && !translateIconElement) {
document.body.removeChild(effectiveIcon);
}
}
}
GM_registerMenuCommand('Set Default Text Font Size', promptAndSetDefaultInitialFontSize);
function onFontSizeAdjustClick(event) {
event.stopPropagation();
const adjustment = parseInt(this.dataset.adjustment, 10);
const bboxDiv = this.closest(`.${SCRIPT_PREFIX}bbox`);
if (!bboxDiv) return;
let currentSizeString = bboxDiv.style.getPropertyValue('--current-font-size');
if (!currentSizeString) {
currentSizeString = getConfiguredInitialFontSize();
}
let currentSizePx = parseFloat(currentSizeString);
currentSizePx += adjustment * FONT_SIZE_ADJUST_STEP;
currentSizePx = Math.max(LOCAL_MIN_FONT_SIZE_PX, Math.min(currentSizePx, LOCAL_MAX_FONT_SIZE_PX));
bboxDiv.style.setProperty('--current-font-size', `${currentSizePx}px`);
}
function resetAllVisibleBboxFontSizes() {
const newDefaultFontSize = getConfiguredInitialFontSize();
const allBboxDivs = document.querySelectorAll(`.${SCRIPT_PREFIX}bbox`);
allBboxDivs.forEach(bbox => {
bbox.style.setProperty('--current-font-size', newDefaultFontSize);
});
const effectiveIcon = translateIconElement || document.body.appendChild(document.createElement('div'));
showTemporaryMessageOnIcon(
effectiveIcon,
`Font size for all current boxes reset to default: ${newDefaultFontSize}.`,
false,
3000
);
if (effectiveIcon.parentNode === document.body && !translateIconElement) {
document.body.removeChild(effectiveIcon);
}
}
GM_registerMenuCommand('Reset All Current Font Sizes', resetAllVisibleBboxFontSizes);
// --- Drag, Resize, Border Radius, Font Size Handlers ---
function onDragStart(event) {
if (event.target.classList.contains(`${SCRIPT_PREFIX}resize_handle`) ||
event.target.classList.contains(`${SCRIPT_PREFIX}border_radius_handle`) ||
event.target.classList.contains(`${SCRIPT_PREFIX}font_size_button`) ||
event.button !== 0) return;
activeDraggableBox = this; isDragging = true;
const parentRect = activeDraggableBox.parentNode.getBoundingClientRect();
const boxRect = activeDraggableBox.getBoundingClientRect();
dragOffsetX = event.clientX - (boxRect.left - parentRect.left);
dragOffsetY = event.clientY - (boxRect.top - parentRect.top);
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 parent = activeDraggableBox.parentNode;
if (!parent || !(parent instanceof HTMLElement)) return;
let newX_px = event.clientX - dragOffsetX, newY_px = event.clientY - dragOffsetY;
const maxX_px = parent.offsetWidth - activeDraggableBox.offsetWidth;
const maxY_px = parent.offsetHeight - activeDraggableBox.offsetHeight;
newX_px = Math.max(0, Math.min(newX_px, maxX_px)); newY_px = Math.max(0, Math.min(newY_px, maxY_px));
activeDraggableBox.style.left = (newX_px / parent.offsetWidth) * 100 + '%';
activeDraggableBox.style.top = (newY_px / parent.offsetHeight) * 100 + '%';
}
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; isResizing = true;
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 deltaX = event.clientX - initialResizeMouseX, deltaY = event.clientY - initialResizeMouseY;
let newWidth_px = initialResizeBoxWidth + deltaX, newHeight_px = initialResizeBoxHeight + deltaY;
newWidth_px = Math.max(minResizeWidth, Math.min(newWidth_px, maxResizeWidth));
newHeight_px = Math.max(minResizeHeight, Math.min(newHeight_px, maxResizeHeight));
const parentOverlay = activeResizeBox.parentNode;
if (parentOverlay && parentOverlay instanceof HTMLElement) {
const currentLeftPercent = parseFloat(activeResizeBox.style.left || 0);
const currentTopPercent = parseFloat(activeResizeBox.style.top || 0);
const currentLeftPx = (currentLeftPercent / 100) * parentOverlay.offsetWidth;
const currentTopPx = (currentTopPercent / 100) * parentOverlay.offsetHeight;
newWidth_px = Math.min(newWidth_px, parentOverlay.offsetWidth - currentLeftPx);
newHeight_px = Math.min(newHeight_px, parentOverlay.offsetHeight - currentTopPx);
}
newWidth_px = Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION, newWidth_px);
newHeight_px = Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION, newHeight_px);
activeResizeBox.style.width = (newWidth_px / parentOverlay.offsetWidth) * 100 + '%';
activeResizeBox.style.height = (newHeight_px / parentOverlay.offsetHeight) * 100 + '%';
}
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);
}
function onBorderRadiusHandleMouseDown(event) {
if (event.button !== 0) return; event.stopPropagation(); event.preventDefault();
isAdjustingBorderRadius = true;
activeBorderRadiusBoxBg = this.parentNode.querySelector(`.${SCRIPT_PREFIX}text_actual_bg`);
if (!activeBorderRadiusBoxBg) return;
initialBorderRadiusMouseX = event.clientX;
const currentRadiusStyle = activeBorderRadiusBoxBg.style.borderRadius || GM_getValue(BORDER_RADIUS_STORAGE_KEY, DEFAULT_BORDER_RADIUS);
initialBorderRadiusValue = parseFloat(currentRadiusStyle) || parseFloat(DEFAULT_BORDER_RADIUS) || 0;
this.classList.add(`${SCRIPT_PREFIX}border_radius_handle_active`);
document.addEventListener('mousemove', onBorderRadiusHandleMouseMove);
document.addEventListener('mouseup', onBorderRadiusHandleMouseUp);
document.addEventListener('mouseleave', onDocumentMouseLeave);
}
function onBorderRadiusHandleMouseMove(event) {
if (!isAdjustingBorderRadius || !activeBorderRadiusBoxBg) return; event.preventDefault();
const deltaX = event.clientX - initialBorderRadiusMouseX;
let newRadiusPercent = initialBorderRadiusValue + deltaX * 0.1;
newRadiusPercent = Math.max(0, Math.min(newRadiusPercent, 50));
activeBorderRadiusBoxBg.style.borderRadius = `${newRadiusPercent.toFixed(1)}%`;
}
function onBorderRadiusHandleMouseUp() {
if (!isAdjustingBorderRadius) return;
if (activeBorderRadiusBoxBg && activeBorderRadiusBoxBg.parentNode) {
const handle = activeBorderRadiusBoxBg.parentNode.querySelector(`.${SCRIPT_PREFIX}border_radius_handle_active`);
if (handle) handle.classList.remove(`${SCRIPT_PREFIX}border_radius_handle_active`);
GM_setValue(BORDER_RADIUS_STORAGE_KEY, activeBorderRadiusBoxBg.style.borderRadius);
}
isAdjustingBorderRadius = false; activeBorderRadiusBoxBg = null;
document.removeEventListener('mousemove', onBorderRadiusHandleMouseMove);
document.removeEventListener('mouseup', onBorderRadiusHandleMouseUp);
document.removeEventListener('mouseleave', onDocumentMouseLeave);
}
function onDocumentMouseLeave(event) {
if (isDragging) onDragEnd();
if (isResizing) onResizeEnd();
if (isAdjustingBorderRadius) onBorderRadiusHandleMouseUp();
}
// --- Core Logic & Display ---
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 = () => resolve({ dataUrl: reader.result, base64Content: reader.result.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 = () => {
const ratio = Math.min(targetMaxDimension / originalWidth, targetMaxDimension / originalHeight);
const resizedWidth = Math.floor(originalWidth * ratio), 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() || 'Not provided';
const targetLanguageName = getTargetLanguageName();
const promptText = GEMINI_PROMPT_TEMPLATE
.replace(/\$\{imageProcessedWidth\}/g, imageProcessedWidth)
.replace(/\$\{imageProcessedHeight\}/g, imageProcessedHeight)
.replace(/\$\{mangaTitle\}/g, mangaTitleText)
.replace(/\$\{targetLanguageName\}/g, targetLanguageName);
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 { const errorJson = JSON.parse(response.responseText); errorMsg = `API Error ${response.status}: ${errorJson.error?.message || response.statusText}`; } catch (e) {}
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();
const overlayContainer = document.createElement('div');
overlayContainer.className = `${SCRIPT_PREFIX}overlay_container`;
overlayContainer.dataset.targetImgSrc = imgElement.src;
Object.assign(overlayContainer.style, { top: `${imgElement.offsetTop}px`, left: `${imgElement.offsetLeft}px`, width: `${imgRect.width}px`, height: `${imgRect.height}px` });
parentNode.appendChild(overlayContainer);
const currentBorderRadius = GM_getValue(BORDER_RADIUS_STORAGE_KEY, DEFAULT_BORDER_RADIUS);
const initialFontSizeForNewBox = getConfiguredInitialFontSize();
translations.forEach((item, index) => {
if (!item.bbox || typeof item.bbox.x_ratio !== 'number' || typeof item.bbox.y_ratio !== 'number' ||
typeof item.bbox.width_ratio !== 'number' || typeof item.bbox.height_ratio !== 'number') {
console.warn(`[DEBUG] displayTranslations: Invalid bbox for item ${index}:`, item); return;
}
const { x_ratio, y_ratio, width_ratio, height_ratio } = item.bbox;
const percentX = x_ratio * 100, percentY = y_ratio * 100, percentWidth = width_ratio * 100, percentHeight = height_ratio * 100;
const bboxDiv = document.createElement('div'); bboxDiv.className = `${SCRIPT_PREFIX}bbox`;
Object.assign(bboxDiv.style, { left: `${percentX}%`, top: `${percentY}%`, width: `${percentWidth}%`, height: `${percentHeight}%` });
bboxDiv.style.setProperty('--current-font-size', initialFontSizeForNewBox);
const textActualBg = document.createElement('div'); textActualBg.className = `${SCRIPT_PREFIX}text_actual_bg`;
textActualBg.style.borderRadius = currentBorderRadius;
const textDisplay = document.createElement('div'); textDisplay.className = `${SCRIPT_PREFIX}text_display`;
textDisplay.textContent = item.text || '';
bboxDiv.appendChild(textActualBg); bboxDiv.appendChild(textDisplay);
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);
const borderRadiusHandle = document.createElement('div'); borderRadiusHandle.className = `${SCRIPT_PREFIX}border_radius_handle`;
borderRadiusHandle.addEventListener('mousedown', onBorderRadiusHandleMouseDown);
bboxDiv.appendChild(borderRadiusHandle);
const fontSizeMinusButton = document.createElement('div');
fontSizeMinusButton.className = `${SCRIPT_PREFIX}font_size_button ${SCRIPT_PREFIX}font_size_minus_button`;
fontSizeMinusButton.textContent = '-';
fontSizeMinusButton.dataset.adjustment = '-1';
fontSizeMinusButton.addEventListener('click', onFontSizeAdjustClick);
bboxDiv.appendChild(fontSizeMinusButton);
const fontSizePlusButton = document.createElement('div');
fontSizePlusButton.className = `${SCRIPT_PREFIX}font_size_button ${SCRIPT_PREFIX}font_size_plus_button`;
fontSizePlusButton.textContent = '+';
fontSizePlusButton.dataset.adjustment = '1';
fontSizePlusButton.addEventListener('click', onFontSizeAdjustClick);
bboxDiv.appendChild(fontSizePlusButton);
overlayContainer.appendChild(bboxDiv);
});
}
async function handleTranslateClick(event) {
event.stopPropagation(); const icon = event.target; translateIconElement = icon;
if (icon.classList.contains('processing')) {
icon.dataset.isTranslating = 'true'; // Ensure flag is aligned if already processing
return;
}
icon.dataset.isTranslating = 'true'; // Set flag for new translation process
const currentImgElement = activeImageTarget;
if (!currentImgElement) { // Safeguard
icon.dataset.isTranslating = 'false';
return;
}
const apiKey = getGeminiApiKey(icon);
if (!apiKey) {
icon.dataset.isTranslating = 'false'; // Clear flag if bailing early
return;
}
const originalIconText = icon.dataset.originalText || 'Translate';
icon.dataset.originalText = originalIconText;
showTemporaryMessageOnIcon(icon, 'Processing...', false, 120000);
icon.classList.remove('success', 'error'); icon.classList.add('processing'); // .processing class is key
removeAllOverlays(currentImgElement);
try {
const naturalWidth = currentImgElement.naturalWidth, naturalHeight = currentImgElement.naturalHeight;
if (naturalWidth === 0 || naturalHeight === 0) throw new Error('Invalid source image (0x0).');
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, 'Translated!', false, 3000);
icon.classList.remove('processing', 'error'); icon.classList.add('success');
} else {
showTemporaryMessageOnIcon(icon, 'No text found!', false, 3000);
icon.classList.remove('processing', 'success', 'error');
}
} catch (error) {
console.error('Manga Translator: Translation failed:', error);
showTemporaryMessageOnIcon(icon, `Error: ${error.message.substring(0, 100)}...`, true, 7000);
icon.classList.remove('processing', 'success'); icon.classList.add('error'); // .error implies not .processing
} finally {
icon.dataset.isTranslating = 'false'; // Clear the core translation task flag
// This check ensures that if the translation ended without explicitly setting success/error (e.g. an early exit in try block not caught, though unlikely now)
// OR if a message timeout from a previous state is still pending, it gets cleaned up.
// However, showTemporaryMessageOnIcon handles its own revert.
// The .processing class is the main indicator used by showTemporaryMessageOnIcon for its message.
// If it's success or error, processing is already removed.
// This is mostly a fallback.
if (icon.classList.contains('processing') && !icon.classList.contains('success') && !icon.classList.contains('error')) {
icon.textContent = originalIconText;
icon.classList.remove('processing');
}
}
}
// --- Icon Management and Image Scanning ---
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 = 'Translate';
icon.className = `${SCRIPT_PREFIX}translate_icon`;
icon.dataset.targetSrc = imgElement.src;
icon.dataset.originalText = 'Translate';
const imgRect = imgElement.getBoundingClientRect(), parentRect = parentNode.getBoundingClientRect();
icon.style.top = `${imgElement.offsetTop + 5}px`;
icon.style.right = `${parentNode.offsetWidth - (imgElement.offsetLeft + imgElement.offsetWidth) + 5}px`;
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';
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}"]`)) {
translateIconElement = addTranslateIcon(img);
} else {
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}"]`);
if (iconExists) {
if (iconExists.dataset.isTranslating === 'true') {
return; // Don't remove if core translation task is active
}
const originalButtonText = iconExists.dataset.originalText || 'Translate';
if (iconExists.textContent !== originalButtonText) {
return; // Don't remove if a temporary message (like "Translated!") is being shown
}
// If passed above checks, proceed with hover-based removal logic
const isMouseOverImg = img.matches(':hover');
const isMouseOverIcon = iconExists.matches(':hover'); // iconExists is confirmed true here
if (!isMouseOverImg && !isMouseOverIcon) {
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)) {
// Mouse moved from img/icon to the parent itself, don't remove.
} else if (shouldRemove) {
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';
});
}
// --- Initialization and Observer ---
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) - v${SCRIPT_VERSION} loaded. Open console for DEBUG logs.`);
})();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址