Translate manga images on any webpage using your local MangaTrans server
// ==UserScript==
// @name MangaTrans
// @namespace https://mangatrans.app
// @version 1.0.9
// @description Translate manga images on any webpage using your local MangaTrans server
// @author MangaTrans
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_registerMenuCommand
// @license MIT
// @connect 127.0.0.1
// @connect localhost
// @connect *
// @run-at document-idle
// ==/UserScript==
;(function () {
'use strict'
// ── Cotrans-style robust GM API polyfill ──
let GMP
let HAS_NATIVE_GM_XHR = false
{
const GMPFunctionMap = {
xmlHttpRequest: typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : undefined,
setValue: typeof GM_setValue !== 'undefined' ? GM_setValue : undefined,
getValue: typeof GM_getValue !== 'undefined' ? GM_getValue : undefined,
registerMenuCommand: typeof GM_registerMenuCommand !== 'undefined' ? GM_registerMenuCommand : undefined,
}
// Fallback implementations for local storage
if (!GMPFunctionMap.getValue) {
GMPFunctionMap.getValue = (key, def) => {
try {
const v = localStorage.getItem('__mt_' + key)
return v === null ? def : JSON.parse(v)
} catch (_) { return def }
}
}
if (!GMPFunctionMap.setValue) {
GMPFunctionMap.setValue = (key, val) => {
try { localStorage.setItem('__mt_' + key, JSON.stringify(val)) } catch (_) {}
}
}
if (!GMPFunctionMap.registerMenuCommand) {
GMPFunctionMap.registerMenuCommand = () => {}
}
const _GM = typeof GM !== 'undefined' ? GM : {}
const xmlHttpRequest = _GM.xmlHttpRequest?.bind(_GM) || GMPFunctionMap.xmlHttpRequest
HAS_NATIVE_GM_XHR = typeof xmlHttpRequest === 'function'
GMP = new Proxy(_GM, {
get(target, prop) {
if (prop === 'xmlHttpRequest') {
return (context) => {
return new Promise((resolve, reject) => {
if (!xmlHttpRequest) {
// Absolute last resort fallback to standard fetch (will fail on CORS)
const ctrl = new AbortController()
fetch(context.url, {
method: context.method || 'GET',
headers: context.headers,
body: context.data,
signal: ctrl.signal
})
.then(async res => {
let response = context.responseType === 'json' ? await res.json() :
context.responseType === 'blob' ? await res.blob() :
await res.text()
const ev = { status: res.status, statusText: res.statusText, response }
res.ok ? (context.onload?.(ev), resolve(ev)) : (context.onerror?.(ev), reject(ev))
})
.catch(err => {
const ev = { statusText: err.message }
context.onerror?.(ev); reject(ev)
})
return
}
xmlHttpRequest({
...context,
onload(event) {
context.onload?.(event)
resolve(event)
},
onerror(event) {
context.onerror?.(event)
reject(event)
},
ontimeout(event) {
context.ontimeout?.(event)
reject(event || new Error('Timeout'))
}
})
})
}
}
if (prop in target) {
const v = target[prop]
return typeof v === 'function' ? v.bind(target) : v
}
if (prop in GMPFunctionMap && typeof GMPFunctionMap[prop] === 'function') {
return GMPFunctionMap[prop]
}
return undefined
}
})
}
const DEFAULT_API_URL = 'http://127.0.0.1:5000'
const MARKER_SIZE = 55
const MAX_CONCURRENT = 3
const SCAN_INTERVAL = 500
const HASH_CHECK_INTERVAL = 1000
const AUTO_TRANSLATE_VISIBLE_RATIO = 0.15
const AUTO_TRANSLATE_MAX_PER_PASS = 3
const DEBUG_LOG_LIMIT = 300
// ── Inline SVG icon as data URI ──
const MARKER_ICON_URI =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62 62">' +
'<path fill="#FFF" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'd="M31,52.3C19.2,52.3,9.7,42.7,9.7,31c0-4.7,1.5-9.2,4.4-12.9c1.6-2.1,4.5-2.4,6.7-0.9c2.1,1.6,2.4,' +
'4.5,0.9,6.7c-1.6,2.1-2.4,4.5-2.4,7.2c0,6.5,5.2,11.8,11.8,11.8s11.8-5.2,11.8-11.8s-5.2-11.8-11.8-' +
'11.8c-2.6,0-4.8-2.2-4.8-4.8s2.1-4.8,4.7-4.8c11.8,0,21.3,9.6,21.3,21.3C52.3,42.8,42.8,52.3,31,52.3z"/>' +
'</svg>'
)
// ── Inline CSS ──
const OVERLAY_CSS = `
.mt-marker{position:absolute;display:flex;align-items:flex-start;justify-content:center;pointer-events:auto;z-index:2147483640}
.mt-marker__icon{cursor:pointer;opacity:.35;scale:.85;transition:scale .2s ease,opacity .2s ease;filter:drop-shadow(0 2px 4px rgba(0,0,0,.35))}
.mt-marker__icon:hover{opacity:1;scale:.95}
.mt-marker__icon--loading{animation:mt-spin 1s linear infinite;opacity:1!important}
.mt-marker__icon--error{filter:drop-shadow(0 0 8px rgba(239,68,68,.8));opacity:1!important}
@keyframes mt-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
.mt-settings-btn{position:fixed;bottom:20px;right:20px;z-index:2147483641;width:42px;height:42px;border-radius:50%;
background:rgba(0,0,0,.75);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 12px rgba(0,0,0,.3);transition:background .2s;pointer-events:auto}
.mt-settings-btn:hover{background:rgba(0,0,0,.9)}
.mt-settings-btn svg{width:22px;height:22px;fill:#fff;pointer-events:none}
.mt-panel{position:fixed;bottom:72px;right:20px;z-index:2147483641;width:300px;background:#1a1a1a;color:#eee;
border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.5);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-size:13px;overflow:hidden;display:none;pointer-events:auto}
.mt-panel.open{display:block}
.mt-panel-header{padding:14px 16px;font-size:15px;font-weight:600;border-bottom:1px solid rgba(255,255,255,.1);display:flex;justify-content:space-between;align-items:center}
.mt-panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:10px}
.mt-row{display:flex;justify-content:space-between;align-items:center}
.mt-row label{color:#aaa}
.mt-row select,.mt-row input[type=text],.mt-row input[type=password]{
background:#2a2a2a;color:#eee;border:1px solid #444;border-radius:6px;padding:6px 8px;font-size:12px;width:150px;box-sizing:border-box}
.mt-row .mt-btn{
background:#2a2a2a;color:#eee;border:1px solid #444;border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer}
.mt-row .mt-btn:hover{background:#333}
.mt-status{
width:150px;text-align:right;color:#8bc34a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
.mt-status.mt-status--error{color:#ef5350}
.mt-switch{position:relative;width:40px;height:22px}
.mt-switch input{opacity:0;width:0;height:0}
.mt-switch .mt-slider{position:absolute;cursor:pointer;inset:0;background:#555;border-radius:22px;transition:.3s}
.mt-switch .mt-slider::before{content:"";position:absolute;width:16px;height:16px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s}
.mt-switch input:checked+.mt-slider{background:#4caf50}
.mt-switch input:checked+.mt-slider::before{transform:translateX(18px)}
`
// ── State ──
let enabled = true
let autoTranslateEnabled = false
let autoTranslateRunning = false
const executingPromises = new Set()
const trackedImages = new Map()
const cursorPos = { x: 0, y: 0 }
const siteKey = 'mt_site_' + location.host
const siteAutoKey = siteKey + '_auto'
const globalEnabledKey = 'mt_enabled_global'
// GM.setValue/getValue errors are swallowed everywhere below (some
// userscript managers silently fail here), and unlike the provider
// config (which mirrors to localStorage via writeLocalConfig/
// readLocalConfig), enable/auto-translate had no local fallback at all -
// so a flaky GM storage backend meant they always reset to the default
// on next page load. Mirror them to localStorage the same way.
function readLocalFlag(key, def) {
try {
const v = localStorage.getItem('__mt_' + key)
return v === null ? def : JSON.parse(v)
} catch (_) {
return def
}
}
function writeLocalFlag(key, val) {
try { localStorage.setItem('__mt_' + key, JSON.stringify(val)) } catch (_) {}
}
async function getSiteFlag(key, def) {
let gmVal
try {
// Omit the 2nd arg entirely rather than passing a literal `undefined` -
// some iOS Safari userscript managers (e.g. the Userscripts extension)
// bridge GM_getValue to native code and can choke on an explicit
// `undefined` argument even though it's JS-equivalent to omitting it.
// This is the one call site that broke iPad Safari between 1.0.5 and
// 1.0.6; every other GM.getValue call in this file always passes a
// concrete fallback value.
gmVal = GMP.getValue(key)
if (gmVal instanceof Promise) gmVal = await gmVal
} catch (_) {
gmVal = undefined
}
if (gmVal !== undefined && gmVal !== null) {
writeLocalFlag(key, gmVal) // keep local mirror in sync
return gmVal
}
// GM storage returned nothing (unset, or silently failing) - fall back
// to the local mirror instead of the hardcoded default.
return readLocalFlag(key, def)
}
function setSiteFlag(key, val) {
writeLocalFlag(key, val)
try {
const r = GMP.setValue(key, val)
if (r instanceof Promise) r.catch(() => {})
} catch (_) {}
}
const DEBUG_LOG_KEY = '__mt_debug_logs'
let debugLogs = []
let statusEl = null
function shortError(err) {
if (!err) return 'Unknown error'
if (typeof err === 'string') return err
return err.message || err.statusText || err.name || 'Unknown error'
}
function pushDebugLog(level, message, extra = null) {
const line = `[${new Date().toISOString()}] [${level}] ${message}${extra ? ` | ${extra}` : ''}`
debugLogs.push(line)
if (debugLogs.length > DEBUG_LOG_LIMIT) debugLogs = debugLogs.slice(-DEBUG_LOG_LIMIT)
try { localStorage.setItem(DEBUG_LOG_KEY, JSON.stringify(debugLogs)) } catch (_) {}
if (level === 'ERROR' || level === 'WARN') {
console.error('[MangaTrans]', line)
} else {
console.log('[MangaTrans]', line)
}
}
function loadDebugLogs() {
try {
const raw = localStorage.getItem(DEBUG_LOG_KEY)
const arr = raw ? JSON.parse(raw) : []
debugLogs = Array.isArray(arr) ? arr.slice(-DEBUG_LOG_LIMIT) : []
} catch (_) {
debugLogs = []
}
}
function clearDebugLogs() {
debugLogs = []
try { localStorage.removeItem(DEBUG_LOG_KEY) } catch (_) {}
}
function formatDebugLogs() {
return [
`Time: ${new Date().toISOString()}`,
`UA: ${navigator.userAgent}`,
`URL: ${location.href}`,
`GM_XHR: ${HAS_NATIVE_GM_XHR ? 'native' : 'fallback-fetch'}`,
`createImageBitmap: ${typeof createImageBitmap === 'function'}`,
`OffscreenCanvas: ${typeof OffscreenCanvas !== 'undefined'}`,
'',
...debugLogs
].join('\n')
}
async function copyDebugLogs() {
const text = formatDebugLogs()
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
throw new Error('Clipboard API unavailable')
}
setStatus('Debug copied', false)
return true
} catch (_) {
// iOS fallback: prompt allows manual copy.
try {
window.prompt('Copy MangaTrans debug logs', text)
} catch (_) {}
setStatus('Debug ready', false)
return false
}
}
function setStatus(message, isError = false) {
pushDebugLog(isError ? 'ERROR' : 'INFO', message)
if (!statusEl) return
statusEl.textContent = message || ''
statusEl.classList.toggle('mt-status--error', !!isError)
statusEl.title = message || ''
}
// ── Config helpers (Async-safe GM Storage) ──
const CONFIG_KEY = 'mangatrans_config'
const CONFIG_JSON_KEY = 'mangatrans_config_json'
const LOCAL_CONFIG_KEY = '__mt_global_mangatrans_config'
let _cachedConfig = null
let _saveConfigPromise = Promise.resolve()
function readLocalConfig() {
try {
const raw = localStorage.getItem(LOCAL_CONFIG_KEY)
return raw ? JSON.parse(raw) : null
} catch (_) {
return null
}
}
function writeLocalConfig(cfg) {
try {
localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(cfg))
} catch (_) {}
}
async function gmGetValue(key, fallback = null) {
try {
let v = GMP.getValue(key, fallback)
if (v instanceof Promise) v = await v
return v
} catch (_) {
return fallback
}
}
function toPromise(res) {
return res instanceof Promise ? res : Promise.resolve(res)
}
async function initConfig() {
const def = {
tgt_lang: 'en',
provider: 'deepl',
server_url: DEFAULT_API_URL,
apiKeys: { grok: '', deepl: '', deepseek: '', chatgpt: '', gemini: '', claude: '', openrouter: '' },
models: { grok: '', deepseek: '', chatgpt: '', gemini: '', claude: '', openrouter: '' }
}
let val = null
// Preferred key: JSON string for maximum userscript-manager compatibility.
const rawJson = await gmGetValue(CONFIG_JSON_KEY, null)
if (typeof rawJson === 'string' && rawJson.trim()) {
try { val = JSON.parse(rawJson) } catch (_) {}
} else if (rawJson && typeof rawJson === 'object') {
val = rawJson
}
// Backward compatibility with previous key.
if (!val) {
const legacy = await gmGetValue(CONFIG_KEY, null)
if (typeof legacy === 'string' && legacy.trim()) {
try { val = JSON.parse(legacy) } catch (_) {}
} else if (legacy && typeof legacy === 'object') {
val = legacy
}
}
// Local fallback if GM storage is unavailable on this engine.
if (!val) val = readLocalConfig()
// Merge with defaults.
_cachedConfig = {
...def,
...(val || {}),
apiKeys: { ...def.apiKeys, ...(val?.apiKeys || {}) },
models: { ...def.models, ...(val?.models || {}) }
}
// "local" (Qwen3) provider was removed from the backend - migrate anyone
// who still has it saved to a provider the backend actually supports.
if (_cachedConfig.provider === 'local') _cachedConfig.provider = 'deepl'
writeLocalConfig(_cachedConfig)
return _cachedConfig
}
function getConfig() {
if (!_cachedConfig) {
// Fallback if accessed before init (shouldn't happen with updated flow)
return {
tgt_lang: 'en', provider: 'deepl', server_url: DEFAULT_API_URL,
apiKeys: { grok: '', deepl: '', deepseek: '', chatgpt: '', gemini: '', claude: '', openrouter: '' },
models: { grok: '', deepseek: '', chatgpt: '', gemini: '', claude: '', openrouter: '' }
}
}
return _cachedConfig
}
function saveConfig(cfg) {
_cachedConfig = cfg
writeLocalConfig(cfg)
_saveConfigPromise = _saveConfigPromise
.catch(() => {})
.then(() => {
const jobs = [
toPromise(GMP.setValue(CONFIG_JSON_KEY, JSON.stringify(cfg))),
toPromise(GMP.setValue(CONFIG_KEY, cfg))
]
return Promise.allSettled(jobs).then(() => {})
})
.catch((e) => {
console.warn('[MangaTrans] saveConfig failed:', e)
})
return _saveConfigPromise
}
function normalizeServerUrl(rawUrl) {
let url = String(rawUrl || '').trim() || DEFAULT_API_URL
if (!/^https?:\/\//i.test(url)) url = 'http://' + url
return url.replace(/\/+$/, '')
}
function shouldPreferGmTransport(baseUrl) {
try {
const target = new URL(baseUrl)
const current = new URL(location.href)
return target.origin !== current.origin || (current.protocol === 'https:' && target.protocol === 'http:')
} catch (_) {
return true
}
}
// ── Shadow DOM host for overlays ──
const overlayHost = document.createElement('div')
overlayHost.style.position = 'absolute'
overlayHost.style.top = '0'
overlayHost.style.left = '0'
overlayHost.style.width = '0'
overlayHost.style.height = '0'
overlayHost.style.overflow = 'visible'
overlayHost.style.pointerEvents = 'none' // base layer pass-through
overlayHost.style.zIndex = '2147483640'
const shadow = overlayHost.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = OVERLAY_CSS
shadow.appendChild(style)
document.body.appendChild(overlayHost)
// ── Settings panel (replaces browser popup) ──
function buildSettingsUI() {
const cfg = getConfig()
const container = document.createElement('div')
container.innerHTML = `
<button class="mt-settings-btn" title="MangaTrans Settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94a7.07 7.07 0 0 0 .06-.94 7.07 7.07 0 0 0-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a7.04 7.04 0 0 0-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.48.41l-.36 2.54a7.04 7.04 0 0 0-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 9.41a.49.49 0 0 0 .12.61l2.03 1.58a7.07 7.07 0 0 0-.06.94c0 .32.02.64.06.94L2.86 15.06a.49.49 0 0 0-.12.61l1.92 3.32a.49.49 0 0 0 .59.22l2.39-.96c.5.37 1.04.68 1.62.94l.36 2.54a.48.48 0 0 0 .48.41h3.84a.48.48 0 0 0 .48-.41l.36-2.54a7.04 7.04 0 0 0 1.62-.94l2.39.96a.49.49 0 0 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58zM12 15.6A3.6 3.6 0 1 1 15.6 12 3.6 3.6 0 0 1 12 15.6z"/></svg>
</button>
<div class="mt-panel">
<div class="mt-panel-header"><span>MangaTrans</span></div>
<div class="mt-panel-body">
<div class="mt-row"><label>Enable</label><label class="mt-switch"><input type="checkbox" id="mt-enabled" ${enabled ? 'checked' : ''}><span class="mt-slider"></span></label></div>
<div class="mt-row"><label>Auto Translate</label><label class="mt-switch"><input type="checkbox" id="mt-auto" ${autoTranslateEnabled ? 'checked' : ''}><span class="mt-slider"></span></label></div>
<div class="mt-row"><label>Target Lang</label>
<select id="mt-lang">
<option value="zh-HANS">Chinese (Simplified)</option><option value="zh-HANT">Chinese (Traditional)</option>
<option value="en">English</option><option value="ja">Japanese</option><option value="ko">Korean</option>
<option value="fr">French</option><option value="de">German</option>
</select>
</div>
<div class="mt-row"><label>Provider</label>
<select id="mt-provider">
<option value="deepl">DeepL</option>
<option value="chatgpt">ChatGPT</option><option value="deepseek">DeepSeek</option><option value="grok">Grok</option>
<option value="gemini">Gemini</option><option value="claude">Claude</option><option value="openrouter">OpenRouter</option>
</select>
</div>
<div class="mt-row"><label>Server</label><input type="text" id="mt-server" value="${cfg.server_url || DEFAULT_API_URL}"></div>
<div class="mt-row" id="mt-key-row"><label>API Key</label><input type="password" id="mt-apikey" value="${(cfg.apiKeys && cfg.apiKeys[cfg.provider]) || ''}"></div>
<div class="mt-row" id="mt-model-row" style="display:${cfg.provider === 'deepl' ? 'none' : 'flex'}"><label>Model ID (optional)</label><input type="text" id="mt-model" placeholder="Leave blank for default" value="${(cfg.models && cfg.models[cfg.provider]) || ''}"></div>
<div class="mt-row"><label>Status</label><span id="mt-status" class="mt-status">Idle</span></div>
<div class="mt-row"><label>Debug</label><button type="button" class="mt-btn" id="mt-copy-debug">Copy Logs</button></div>
<div class="mt-row"><label></label><button type="button" class="mt-btn" id="mt-clear-debug">Clear Logs</button></div>
</div>
</div>`
shadow.appendChild(container)
const btn = container.querySelector('.mt-settings-btn')
const panel = container.querySelector('.mt-panel')
btn.addEventListener('click', (e) => {
e.stopPropagation()
panel.classList.toggle('open')
})
const langSel = container.querySelector('#mt-lang')
const provSel = container.querySelector('#mt-provider')
const modelIn = container.querySelector('#mt-model')
const modelRow = container.querySelector('#mt-model-row')
const serverIn = container.querySelector('#mt-server')
const keyIn = container.querySelector('#mt-apikey')
statusEl = container.querySelector('#mt-status')
langSel.value = cfg.tgt_lang || 'en'
provSel.value = cfg.provider || 'deepl'
setStatus('Ready', false)
let persistTimer = null
const persistNow = () => {
const c = getConfig()
const newCfg = {
tgt_lang: langSel.value,
provider: provSel.value,
server_url: normalizeServerUrl(serverIn.value),
apiKeys: { ...c.apiKeys, [provSel.value]: keyIn.value },
models: { ...c.models, [provSel.value]: modelIn.value }
}
return saveConfig(newCfg)
}
const persistSoon = () => {
clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void persistNow()
}, 150)
}
langSel.addEventListener('change', () => { void persistNow() })
provSel.addEventListener('change', () => {
const c = getConfig()
keyIn.value = (c.apiKeys && c.apiKeys[provSel.value]) || ''
modelIn.value = (c.models && c.models[provSel.value]) || ''
modelRow.style.display = provSel.value === 'deepl' ? 'none' : 'flex'
void persistNow()
})
modelIn.addEventListener('input', persistSoon)
modelIn.addEventListener('change', () => { void persistNow() })
serverIn.addEventListener('input', persistSoon)
serverIn.addEventListener('change', () => { void persistNow() })
serverIn.addEventListener('blur', () => { void persistNow() })
keyIn.addEventListener('input', persistSoon)
keyIn.addEventListener('change', () => { void persistNow() })
container.querySelector('#mt-enabled').addEventListener('change', (e) => {
enabled = e.target.checked
setSiteFlag(globalEnabledKey, enabled)
if (!enabled) removeAllMarkers()
})
container.querySelector('#mt-auto').addEventListener('change', (e) => {
autoTranslateEnabled = e.target.checked
setSiteFlag(siteAutoKey, autoTranslateEnabled)
if (autoTranslateEnabled) requestAutoTranslate()
})
document.addEventListener('click', (e) => {
const path = typeof e.composedPath === 'function' ? e.composedPath() : []
if (panel.classList.contains('open') && !path.includes(container)) {
panel.classList.remove('open')
}
})
window.addEventListener('beforeunload', () => {
clearTimeout(persistTimer)
void persistNow()
})
container.querySelector('#mt-copy-debug').addEventListener('click', () => {
void copyDebugLogs()
})
container.querySelector('#mt-clear-debug').addEventListener('click', () => {
clearDebugLogs()
setStatus('Logs cleared', false)
})
}
// ── GM_xmlhttpRequest promise wrapper ──
function gmFetch(url, opts = {}) {
return GMP.xmlHttpRequest({
method: opts.method || 'GET',
url,
headers: opts.headers || {},
data: opts.body || undefined,
responseType: opts.responseType || 'blob',
// Without a timeout, a GM_xmlhttpRequest that never calls onload/
// onerror (observed hanging indefinitely on iPad Safari, likely a
// silently-blocked LAN request) leaves the caller awaiting forever.
timeout: opts.timeout || 20000
}).then(res => {
if (res.status >= 200 && res.status < 300) {
return res
}
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}).catch(err => {
throw new Error('Network error: ' + (err.statusText || 'unknown'))
})
}
// ── Translation API (merged from background.js) ──
// Returns { blob } or { url } — caller decides how to submit
async function resolveImage(targetElement) {
const url = await getTargetUrl(targetElement)
if (!url) throw new Error('No image URL found')
if (url.startsWith('data:')) {
const res = await fetch(url)
return { blob: await res.blob() }
}
// Strategy 1: canvas drawImage (fastest, no network, works for rendered images)
try {
return { blob: await canvasFallback(targetElement), url }
} catch (_e) {}
// Strategy 2: regular fetch (works for same-origin / CORS-allowed)
try {
const res = await fetch(url)
if (res.ok) {
let blob = await res.blob()
const type = (blob.type || '').toLowerCase()
if (type.includes('avif') || type.includes('webp')) blob = await convertBlobToPng(blob)
return { blob, url }
}
} catch (_e) {}
// Strategy 3: GMP.xmlHttpRequest (bypasses CORS). Unlike page-context fetch,
// GM_xmlhttpRequest isn't bound by the forbidden-header list, so Referer
// actually takes effect here - helps with hotlink-protected image hosts.
try {
const gmRes = await withTimeout(
gmFetch(url, { responseType: 'blob', headers: { Referer: location.href } }),
15000
)
let blob = gmRes.response
const type = (blob.type || '').toLowerCase()
if (type.includes('avif') || type.includes('webp')) blob = await convertBlobToPng(blob)
return { blob, url }
} catch (_e) {}
// Strategy 4: give up fetching client-side, send URL to server for proxy download
if (url && !url.startsWith('blob:')) {
return { url }
}
throw new Error('All image fetch strategies failed')
}
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms))
])
}
async function blobToBase64(blob) {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('FileReader failed'))
reader.readAsDataURL(blob)
})
const str = String(dataUrl || '')
const comma = str.indexOf(',')
return comma >= 0 ? str.slice(comma + 1) : str
}
function canvasFallback(el) {
const canvas = document.createElement('canvas')
const w = el.naturalWidth || el.width
const h = el.naturalHeight || el.height
if (!w || !h) throw new Error('Cannot determine image dimensions')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
ctx.drawImage(el, 0, 0, w, h)
return new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))), 'image/png')
})
}
async function convertBlobToPng(blob) {
if (typeof createImageBitmap === 'function' && typeof OffscreenCanvas !== 'undefined') {
const bitmap = await createImageBitmap(blob)
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
canvas.getContext('2d').drawImage(bitmap, 0, 0)
return await canvas.convertToBlob({ type: 'image/png' })
} finally {
bitmap.close()
}
}
// iOS Safari fallback: convert via HTMLCanvasElement + Image.
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('FileReader failed'))
reader.readAsDataURL(blob)
})
const img = await new Promise((resolve, reject) => {
const el = new Image()
el.onload = () => resolve(el)
el.onerror = () => reject(new Error('Image decode failed'))
el.src = String(dataUrl)
})
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
canvas.getContext('2d').drawImage(img, 0, 0)
return await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))), 'image/png')
})
}
// imageData: { blob } or { url }
async function submitTranslationJob(imageData) {
const cfg = getConfig()
const provider = cfg.provider || 'deepl'
const apiKey = (cfg.apiKeys && cfg.apiKeys[provider]) || ''
const customModel = (cfg.models && cfg.models[provider]) || ''
let baseUrl = normalizeServerUrl(cfg.server_url || DEFAULT_API_URL)
const apiUrl = baseUrl + '/process'
const preferGmTransport = shouldPreferGmTransport(baseUrl)
const isHttpsToHttp = location.protocol === 'https:' && baseUrl.startsWith('http://')
setStatus(`Sending to ${baseUrl}`, false)
pushDebugLog('INFO', 'submitTranslationJob', `provider=${provider}; preferGM=${preferGmTransport}; mixed=${isHttpsToHttp}`)
// iOS Safari without native GM XHR cannot bypass mixed-content restrictions.
if (isHttpsToHttp && !HAS_NATIVE_GM_XHR) {
pushDebugLog('ERROR', 'Blocked by mixed-content without native GM XHR', `page=${location.protocol}; server=${baseUrl}`)
throw new Error('Current userscript engine cannot access HTTP server from HTTPS page on iPad Safari. Use HTTPS server URL or open an HTTP page.')
}
const buildFormData = () => {
const fd = new FormData()
if (imageData.blob) fd.append('files', imageData.blob, 'image.png')
if (imageData.url) fd.append('image_urls', imageData.url)
fd.append('tgt_lang', cfg.tgt_lang || 'en')
fd.append('provider', provider)
if (apiKey) fd.append(provider + '_key', apiKey)
if (customModel && provider !== 'deepl') fd.append(provider + '_model', customModel)
return fd
}
const parsePayload = (payload) => {
const result = (payload.results || [])[0]
if (!result) throw new Error('No translation result')
return result.rendered_image
}
const getErrorDetail = (payload) => {
if (!payload) return ''
if (typeof payload === 'string') return payload
if (typeof payload.detail === 'string') return payload.detail
try { return JSON.stringify(payload) } catch (_) { return '' }
}
const submitByFetch = async () => {
const res = await fetch(apiUrl, { method: 'POST', body: buildFormData() })
if (!res.ok) {
let detail = ''
try {
const payload = await res.json()
detail = getErrorDetail(payload)
} catch (_) {}
throw new Error(`API Error ${res.status}${detail ? `: ${detail}` : ''}`)
}
pushDebugLog('INFO', 'submitByFetch ok', `status=${res.status}`)
return parsePayload(await res.json())
}
const submitByGm = async () => {
const params = new URLSearchParams()
params.append('tgt_lang', cfg.tgt_lang || 'en')
params.append('provider', provider)
if (apiKey) params.append(provider + '_key', apiKey)
if (customModel && provider !== 'deepl') params.append(provider + '_model', customModel)
if (imageData.url) {
params.append('image_urls', imageData.url)
pushDebugLog('INFO', 'GM payload mode', 'image_urls')
} else {
const b64 = await blobToBase64(imageData.blob)
params.append('image_base64', b64)
pushDebugLog('INFO', 'GM payload mode', `image_base64 len=${b64.length}`)
}
const res = await GMP.xmlHttpRequest({
method: 'POST',
url: apiUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: params.toString(),
responseType: 'json',
// Real OCR+translate+inpaint requests can legitimately take over a
// minute on weak/CPU-only hardware, so this is generous - but
// without ANY timeout, a request that never fires onload/onerror
// (observed on iPad Safari: logs a "GM payload mode" line and then
// nothing, ever, until the page itself reloads) hangs the whole
// translation flow forever with no error and no fallback.
timeout: 120000
}).catch((err) => {
if (err && err === Object(err) && !('status' in err)) {
pushDebugLog('ERROR', 'submitByGm GM_xmlhttpRequest timed out or errored with no response', shortError(err))
}
throw err
})
if (res.status >= 400) {
const detail = getErrorDetail(res.response)
throw new Error(`API Error ${res.status}${detail ? `: ${detail}` : ''}`)
}
pushDebugLog('INFO', 'submitByGm ok', `status=${res.status}`)
const payload = typeof res.response === 'string' ? JSON.parse(res.response) : res.response
return parsePayload(payload)
}
if (preferGmTransport) {
try {
return await submitByGm()
} catch (gmErr) {
pushDebugLog('WARN', 'submitByGm failed, fallback to fetch', shortError(gmErr))
return await submitByFetch().catch((fetchErr) => {
pushDebugLog('ERROR', 'submitByFetch after GM fallback failed', shortError(fetchErr))
throw gmErr?.message ? gmErr : fetchErr
})
}
}
try {
return await submitByFetch()
} catch (fetchErr) {
pushDebugLog('WARN', 'submitByFetch failed, fallback to GM', shortError(fetchErr))
return await submitByGm()
}
}
// ── Image scanning & markers ──
function shouldProcessImage(img) {
try {
const isVisible = img.offsetParent !== null
const isLoaded = img.complete && img.naturalWidth > 0
const minW = Math.max(220, Math.floor(window.innerWidth * 0.33))
const minH = Math.max(220, Math.floor(window.innerHeight * 0.33))
const hasSize = img.clientWidth >= minW && img.clientHeight >= minH
return isVisible && hasSize && isLoaded
} catch (_) {
return false
}
}
function attachMarker(targetElement) {
try {
if (targetElement.nodeName.toLowerCase() !== 'img') return
if (targetElement.dataset.mangatransTranslated === '1') return
const rect = targetElement.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
const markerElement = document.createElement('div')
markerElement.classList.add('mt-marker')
const markerIcon = document.createElement('img')
markerIcon.src = MARKER_ICON_URI
markerIcon.classList.add('mt-marker__icon')
markerIcon.style.width = MARKER_SIZE + 'px'
markerIcon.style.height = MARKER_SIZE + 'px'
markerElement.appendChild(markerIcon)
positionMarker(markerElement, rect, MARKER_SIZE)
const onMarkerActivate = (e) => {
e.stopPropagation()
e.stopImmediatePropagation()
e.preventDefault()
handleMarkerClick(targetElement)
}
markerIcon.addEventListener('pointerup', onMarkerActivate, true)
markerIcon.addEventListener('click', onMarkerActivate, true)
markerIcon.addEventListener('touchend', onMarkerActivate, { capture: true, passive: false })
const markerObserver = observeRect(targetElement, (newRect) => {
if (!newRect) return
positionMarker(markerElement, newRect, MARKER_SIZE)
if (!targetElement.isConnected) {
detachMarkerFromImage(targetElement, true)
return
}
updateMarkerVisibility(targetElement, markerElement)
})
const visibilityObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.target !== targetElement) continue
if (entry.intersectionRatio <= 0 || entry.boundingClientRect.width === 0) {
markerElement.style.display = 'none'
} else {
updateMarkerVisibility(targetElement, markerElement)
}
}
},
{ root: null, threshold: 0 }
)
shadow.appendChild(markerElement)
trackedImages.set(targetElement, {
markerElement,
markerIcon,
markerObserver,
visibilityObserver,
markerSize: MARKER_SIZE,
markerState: 'original',
markerHash: null,
originalURL: null,
originalImageData: null
})
markerObserver.observe()
visibilityObserver.observe(targetElement)
updateMarkerVisibility(targetElement, markerElement)
hashElement(targetElement)
.then((hash) => {
const d = trackedImages.get(targetElement)
if (d) d.markerHash = hash
})
.catch(() => {})
if (autoTranslateEnabled) requestAutoTranslate()
} catch (error) {
console.error('[MangaTrans] attachMarker error:', error)
}
}
function handleMarkerClick(targetElement) {
const d = trackedImages.get(targetElement)
if (!d) return
if (targetElement.dataset.mangatransTranslated === '1') {
delete targetElement.dataset.mangatransTranslated
}
if (d.markerState === 'translating') return
if (d.markerState === 'awaiting') return
d.markerIcon.classList.remove('mt-marker__icon--error')
d.markerIcon.classList.add('mt-marker__icon--loading')
d.markerState = 'awaiting'
queueTranslation(targetElement)
}
// ── Translation execution ──
async function translateImage(targetElement) {
const d = trackedImages.get(targetElement)
if (!d) return
const { markerIcon } = d
d.markerHash = null
markerIcon.classList.remove('mt-marker__icon--error')
d.markerState = 'translating'
markerIcon.classList.add('mt-marker__icon--loading')
setStatus('Translating...', false)
try {
removeExtraSources(targetElement)
let imageData = d.originalImageData
if (!imageData) {
imageData = await resolveImage(targetElement)
d.originalImageData = imageData
}
const base64Image = await submitTranslationJob(imageData)
applyTranslationResult(targetElement, d, markerIcon, base64Image)
markerIcon.title = 'MangaTrans: success'
setStatus('Done', false)
} catch (_error) {
const msg = shortError(_error)
markerIcon.classList.remove('mt-marker__icon--loading')
markerIcon.classList.add('mt-marker__icon--error')
d.markerState = 'failed'
markerIcon.title = 'MangaTrans error: ' + msg
setStatus(msg, true)
pushDebugLog('ERROR', 'translateImage failed', msg)
}
}
function applyTranslationResult(targetElement, d, markerIcon, base64Image) {
const translatedSrc = 'data:image/png;base64,' + base64Image
if (targetElement.nodeName.toLowerCase() === 'img') {
targetElement.src = translatedSrc
if (targetElement.srcset) targetElement.srcset = translatedSrc
} else if (targetElement.nodeName.toLowerCase() === 'canvas') {
const newImg = document.createElement('img')
const ctx = targetElement.getContext('2d')
newImg.onload = () => ctx.drawImage(newImg, 0, 0, targetElement.width, targetElement.height)
newImg.src = translatedSrc
}
targetElement.style.opacity = '1'
hashElement(targetElement).then((hash) => {
const latest = trackedImages.get(targetElement)
if (latest) latest.markerHash = hash
})
d.markerState = 'translated'
markerIcon.classList.remove('mt-marker__icon--loading')
targetElement.dataset.mangatransTranslated = '1'
}
async function queueTranslation(targetElement) {
const d = trackedImages.get(targetElement)
if (!d || d.markerState === 'translating') return
if (targetElement.dataset.mangatransTranslated === '1') return
while (executingPromises.size >= MAX_CONCURRENT) {
await Promise.race(executingPromises)
}
const p = translateImage(targetElement)
executingPromises.add(p)
p.finally(() => executingPromises.delete(p))
}
// ── Auto-translate ──
function requestAutoTranslate() {
if (!autoTranslateEnabled) return
// "enabled" is a global toggle but auto-translate stays per-site - without
// this check, a manga site left open in a background tab would keep
// auto-translating concurrently with whatever tab is actually in focus.
if (document.hidden) return
if (autoTranslateRunning) return
queueMicrotask(() => autoTranslateAll().catch((e) => console.error('[MangaTrans] Auto translate failed:', e)))
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) requestAutoTranslate()
})
function getVisibleRatio(rect) {
const vw = window.innerWidth || document.documentElement.clientWidth
const vh = window.innerHeight || document.documentElement.clientHeight
const left = Math.max(0, rect.left)
const right = Math.min(vw, rect.right)
const top = Math.max(0, rect.top)
const bottom = Math.min(vh, rect.bottom)
const w = Math.max(0, right - left)
const h = Math.max(0, bottom - top)
const visibleArea = w * h
const totalArea = Math.max(1, rect.width * rect.height)
return visibleArea / totalArea
}
function getVisibleAutoCandidates() {
const vw = window.innerWidth || document.documentElement.clientWidth
const vh = window.innerHeight || document.documentElement.clientHeight
const cx = vw / 2
const cy = vh / 2
const list = []
for (const [el, d] of trackedImages.entries()) {
if (!el.isConnected) continue
if (!d || d.markerState !== 'original') continue
if (el.dataset.mangatransTranslated === '1') continue
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) continue
const visibleRatio = getVisibleRatio(rect)
if (visibleRatio < AUTO_TRANSLATE_VISIBLE_RATIO) continue
const mx = rect.left + rect.width / 2
const my = rect.top + rect.height / 2
const dist = Math.hypot(mx - cx, my - cy)
list.push({ el, visibleRatio, dist })
}
list.sort((a, b) => {
if (a.visibleRatio !== b.visibleRatio) return b.visibleRatio - a.visibleRatio
return a.dist - b.dist
})
return list.map((x) => x.el)
}
async function autoTranslateAll() {
if (!autoTranslateEnabled || autoTranslateRunning) return
autoTranslateRunning = true
try {
const candidates = getVisibleAutoCandidates().slice(0, AUTO_TRANSLATE_MAX_PER_PASS)
for (const el of candidates) {
if (!autoTranslateEnabled) break
if (!el.isConnected || el.dataset.mangatransTranslated === '1') continue
const d = trackedImages.get(el)
if (!d || d.markerState !== 'original') continue
d.markerIcon.classList.add('mt-marker__icon--loading')
d.markerState = 'awaiting'
try {
await translateImage(el)
} catch (_) {}
}
} finally {
autoTranslateRunning = false
}
if (autoTranslateEnabled) {
const hasVisibleMore = getVisibleAutoCandidates().length > 0
if (hasVisibleMore) setTimeout(requestAutoTranslate, 120)
}
}
// ── Utility functions (from original extension) ──
function removeExtraSources(el) {
try {
if (el.parentNode && el.parentNode.nodeName.toLowerCase() === 'picture') {
const sources = el.parentNode.getElementsByTagName('source')
for (let i = sources.length - 1; i >= 0; i--) el.parentNode.removeChild(sources[i])
}
} catch (_) {}
}
function getTargetUrl(targetElement) {
return new Promise((resolve, reject) => {
if (targetElement.nodeName.toLowerCase() === 'img') {
let url = targetElement.src || targetElement.currentSrc
if (url && url.startsWith('blob:')) {
const d = getImageFromBlob(targetElement)
return d ? resolve(d) : reject(new Error('Failed to get blob image'))
}
if (!url) {
const c = getCorrectImage(targetElement)
return c ? resolve(c) : reject(new Error('No image URL'))
}
return resolve(url)
}
if (targetElement.nodeName.toLowerCase() === 'canvas') {
try {
return resolve(targetElement.toDataURL())
} catch (_) {
return reject(new Error('Canvas tainted'))
}
}
reject(new Error('Unsupported element'))
})
}
function getImageFromBlob(el) {
try {
const canvas = document.createElement('canvas')
canvas.width = el.naturalWidth
canvas.height = el.naturalHeight
canvas.getContext('2d').drawImage(el, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL()
} catch (_) {
return null
}
}
function getCorrectImage(targetElement) {
try {
const elements = document.elementsFromPoint(cursorPos.x, cursorPos.y)
for (const el of elements) {
if (
el.nodeName.toLowerCase() === 'img' &&
el.src &&
el.clientWidth === targetElement.clientWidth &&
el.clientHeight === targetElement.clientHeight &&
!targetElement.isEqualNode(el)
) {
return el.src
}
}
} catch (_) {}
return null
}
async function hashElement(el) {
if (el instanceof HTMLCanvasElement) {
try {
const ctx = el.getContext('2d')
if (!ctx) return null
const imageData = ctx.getImageData(0, 0, el.width, el.height)
return await hashData(imageData.data.buffer)
} catch (_) {
return null
}
}
if (el instanceof HTMLImageElement) {
try {
const data = new TextEncoder().encode(el.src)
return await hashData(data)
} catch (_) {
return null
}
}
return null
}
async function hashData(data) {
if (!crypto.subtle) return null
const buf = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
function detachMarkerFromImage(el, force = false) {
const d = trackedImages.get(el)
if (!d) return
const s = d.markerState
if (force || s === 'original' || s === 'error' || s === 'awaiting' || !el.isConnected) {
d.markerElement?.remove()
d.markerObserver?.unobserve()
d.visibilityObserver?.unobserve?.(el)
d.visibilityObserver?.disconnect?.()
trackedImages.delete(el)
delete el.dataset.mangatransTranslated
}
}
function removeAllMarkers() {
for (const [el, d] of trackedImages) {
d.markerElement?.remove()
d.markerObserver?.unobserve()
d.visibilityObserver?.unobserve?.(el)
d.visibilityObserver?.disconnect?.()
delete el.dataset.mangatransTranslated
}
trackedImages.clear()
}
// ── Rect observation (rAF-based) ──
let rafId = null
const observedNodes = new Map()
const rectProps = ['left', 'top', 'height']
const rectChanged = (a, b) => rectProps.some((p) => a[p] !== b[p])
function runObserver() {
const changed = []
observedNodes.forEach((state, node) => {
const newRect = node.getBoundingClientRect()
if (rectChanged(newRect, state.rect)) {
state.rect = newRect
changed.push(state)
}
})
changed.forEach((s) => s.callbacks.forEach((cb) => cb(s.rect)))
rafId = requestAnimationFrame(runObserver)
}
function observeRect(node, cb) {
return {
observe() {
const wasEmpty = observedNodes.size === 0
if (observedNodes.has(node)) {
observedNodes.get(node).callbacks.push(cb)
} else {
observedNodes.set(node, { rect: node.getBoundingClientRect(), callbacks: [cb] })
}
if (wasEmpty) runObserver()
},
unobserve() {
const state = observedNodes.get(node)
if (state) {
const idx = state.callbacks.indexOf(cb)
if (idx >= 0) state.callbacks.splice(idx, 1)
if (!state.callbacks.length) observedNodes.delete(node)
if (!observedNodes.size) cancelAnimationFrame(rafId)
}
}
}
}
function positionMarker(markerElement, rect, markerSize) {
markerElement.style.left = rect.left + window.scrollX + 'px'
const top = rect.top + window.scrollY
const bottom = rect.bottom + window.scrollY - markerSize
markerElement.style.top = (bottom < top ? top : Math.min(top, bottom)) + 'px'
}
function isSameOrDescendant(target, node) {
let c = node
while (c) {
if (c === target) return true
c = c.parentElement
}
return false
}
function updateMarkerVisibility(el, marker) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
marker.style.display = 'none'
return
}
const midX = rect.left + rect.width / 2
const midY = rect.top + rect.height / 2
const vw = document.documentElement.clientWidth
const vh = document.documentElement.clientHeight
const cx = Math.min(Math.max(midX, 0), Math.max(vw - 1, 0))
const cy = Math.min(Math.max(midY, 0), Math.max(vh - 1, 0))
const topNode = document.elementFromPoint(cx, cy)
marker.style.display = isSameOrDescendant(el, topNode) ? '' : 'none'
}
let autoKickTimer = null
function kickAutoTranslateSoon() {
if (!autoTranslateEnabled) return
clearTimeout(autoKickTimer)
autoKickTimer = setTimeout(() => {
requestAutoTranslate()
}, 80)
}
// ── Mouse tracking ──
document.addEventListener('mousemove', (e) => {
cursorPos.x = e.clientX
cursorPos.y = e.clientY
})
document.addEventListener('touchstart', (e) => {
if (e.targetTouches?.[0]) {
cursorPos.x = e.targetTouches[0].clientX
cursorPos.y = e.targetTouches[0].clientY
}
})
document.addEventListener('touchmove', (e) => {
if (e.targetTouches?.[0]) {
cursorPos.x = e.targetTouches[0].clientX
cursorPos.y = e.targetTouches[0].clientY
}
})
window.addEventListener('scroll', kickAutoTranslateSoon, { passive: true })
window.addEventListener('resize', kickAutoTranslateSoon, { passive: true })
// ── Intervals ──
// Hash-based image change detection
setInterval(async () => {
for (const [el, d] of trackedImages) {
if (!el.isConnected) {
detachMarkerFromImage(el, true)
continue
}
if (d.markerHash) {
const hash = await hashElement(el)
if (hash !== d.markerHash) {
delete el.dataset.mangatransTranslated
detachMarkerFromImage(el, true)
}
}
}
}, HASH_CHECK_INTERVAL)
// Auto-detect images
setInterval(() => {
if (!enabled) return
const images = document.getElementsByTagName('img')
for (const img of images) {
if (shouldProcessImage(img) && !trackedImages.has(img)) attachMarker(img)
}
for (const [target, data] of trackedImages) {
if (data?.markerElement) updateMarkerVisibility(target, data.markerElement)
}
if (autoTranslateEnabled) kickAutoTranslateSoon()
}, SCAN_INTERVAL)
// ── Init ──
async function init() {
loadDebugLogs()
pushDebugLog('INFO', 'init', `ua=${navigator.userAgent}`)
pushDebugLog('INFO', 'env', `gm_xhr=${HAS_NATIVE_GM_XHR}; offscreen=${typeof OffscreenCanvas !== 'undefined'}; cib=${typeof createImageBitmap === 'function'}`)
enabled = !!(await getSiteFlag(globalEnabledKey, true))
autoTranslateEnabled = !!(await getSiteFlag(siteAutoKey, false))
await initConfig()
buildSettingsUI()
if (autoTranslateEnabled) requestAutoTranslate()
GMP.registerMenuCommand('Toggle MangaTrans', () => {
enabled = !enabled
setSiteFlag(globalEnabledKey, enabled)
if (!enabled) removeAllMarkers()
})
GMP.registerMenuCommand('Toggle Auto-Translate', () => {
autoTranslateEnabled = !autoTranslateEnabled
setSiteFlag(siteAutoKey, autoTranslateEnabled)
if (autoTranslateEnabled) requestAutoTranslate()
})
GMP.registerMenuCommand('Copy MangaTrans Debug Logs', () => {
void copyDebugLogs()
})
}
init().catch(e => {
pushDebugLog('ERROR', 'Init failed', shortError(e))
console.error('[MangaTrans] Init failed:', e)
})
})()