MangaTrans

Translate manga images on any webpage using your local MangaTrans server

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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