MangaTrans

Translate manga images on any webpage using your local MangaTrans server

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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)
  })
})()