Universal Image Resizer & Downloader (by Eliminater74)

Resize any image client-side (CORS-safe). Alt+Right-Click or hover chip to open. Blob preview + anchor download. Remembers last settings + last-used format (sticky). Timestamped filenames with tokens. Quick presets + Platform presets dropdown in header.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Universal Image Resizer & Downloader (by Eliminater74)
// @namespace    https://greasyfork.org/en/users/123456-eliminater74
// @version      1.6
// @description  Resize any image client-side (CORS-safe). Alt+Right-Click or hover chip to open. Blob preview + anchor download. Remembers last settings + last-used format (sticky). Timestamped filenames with tokens. Quick presets + Platform presets dropdown in header.
// @author       Eliminater74
// @license      MIT
// @match        *://*/*
// @icon         https://www.tiktok.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @connect      *
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // =========================
  // Defaults + storage
  // =========================
  const DEFAULTS = {
    width: 3000,
    height: 3000,
    mode: 'contain',                 // 'contain' | 'cover' | 'stretch'
    bgColor: '#000000',              // used for letterbox in 'contain'
    format: 'image/jpeg',            // 'image/jpeg' | 'image/png' | 'image/webp'
    quality: 0.95,                   // for JPEG/WEBP
    filenameTpl: '{name}_{w}x{h}_{YYYY}-{MM}-{DD}_{hh}{mm}{ss}', // tokens below
    minImgEdge: 160,                 // only show chip if image larger than this
    showHoverChip: true,
    chipCorner: 'top-left',          // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
    useAltRightClick: true           // true = Alt+RightClick; false = plain RightClick (replaces native menu on images)
  };

  const STORE_KEY = 'uird:lastOptions';
  const STORE_LAST_FORMAT = 'uird:lastFormat';

  const saved = safeGet(STORE_KEY) || {};
  const stickyFmt = safeGet(STORE_LAST_FORMAT);

  let state = {
    selectedImg: null,
    lastHoverImg: null,
    options: { ...DEFAULTS, ...(saved || {}) }
  };
  if (stickyFmt) state.options.format = stickyFmt;  // stick to last used (e.g., JPG)

  // =========================
  // Platform presets
  // =========================
  const PLATFORM_PRESETS = [
    {
      title: 'Suno / Spotify / DistroKid (Covers)',
      items: [
        { label: 'Album Cover 3000×3000', w: 3000, h: 3000 },
        { label: 'Alt Cover 1600×1600',   w: 1600, h: 1600 }
      ]
    },
    {
      title: 'YouTube',
      items: [
        { label: 'Thumbnail 1280×720',       w: 1280, h: 720 },
        { label: 'Channel Banner 2560×1440', w: 2560, h: 1440 },
        { label: 'Channel Icon 800×800',     w: 800,  h: 800 },
        { label: 'Shorts 1080×1920',         w: 1080, h: 1920 }
      ]
    },
    {
      title: 'TikTok',
      items: [
        { label: 'Video 1080×1920',       w: 1080, h: 1920 },
        { label: 'Alt Vertical 720×1280', w: 720,  h: 1280 },
        { label: 'Profile 200×200',       w: 200,  h: 200 }
      ]
    },
    {
      title: 'Instagram',
      items: [
        { label: 'Post Square 1080×1080',   w: 1080, h: 1080 },
        { label: 'Post Portrait 1080×1350', w: 1080, h: 1350 },
        { label: 'Story/Reel 1080×1920',    w: 1080, h: 1920 }
      ]
    }
  ];

  // =========================
  // Tiny DOM helpers
  // =========================
  function h(tag, attrs = {}, children = []) {
    const el = document.createElement(tag);
    Object.entries(attrs).forEach(([k, v]) => {
      if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
      else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
      else el.setAttribute(k, v);
    });
    (Array.isArray(children) ? children : [children]).forEach(c => {
      if (c == null) return;
      el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
    });
    return el;
  }
  function injectCSS(css) { document.head.appendChild(h('style', { type: 'text/css' }, css)); }

  injectCSS(`
    .uird-chip{position:absolute;z-index:2147483647;background:rgba(0,0,0,.85);color:#fff;font:12px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;padding:4px 8px;border:1px solid rgba(255,255,255,.2);border-radius:6px;cursor:pointer;user-select:none;box-shadow:0 2px 8px rgba(0,0,0,.4);pointer-events:auto}
    .uird-panel{position:fixed;right:20px;bottom:20px;width:340px;background:#111;color:#eee;border:1px solid #333;border-radius:10px;z-index:2147483647;box-shadow:0 8px 24px rgba(0,0,0,.6);font:13px/1.3 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
    .uird-panel .hdr{padding:8px 12px;background:#1a1a1a;border-bottom:1px solid #333;border-radius:10px 10px 0 0;display:flex;align-items:center;justify-content:space-between;cursor:move}
    .uird-panel .hdr .left{display:flex;align-items:center;gap:6px}
    .uird-panel .hdr .right{display:flex;align-items:center;gap:6px}
    .uird-panel .body{padding:10px 12px 12px;display:grid;grid-template-columns:1fr 1fr;gap:8px}
    .uird-panel label{opacity:.9;font-size:12px}
    .uird-panel input[type="number"],.uird-panel input[type="text"],.uird-panel select{width:100%;padding:6px 8px;background:#0f0f0f;color:#eee;border:1px solid #333;border-radius:6px;outline:none}
    .uird-panel input[type="color"]{width:100%;height:32px;padding:0;border:1px solid #333;border-radius:6px;background:#0f0f0f}
    .uird-panel .row-2{grid-column:span 2}
    .uird-panel .btns{grid-column:span 2;display:flex;gap:8px}
    .uird-panel button{padding:6px 10px;background:#212121;color:#fff;border:1px solid #3a3a3a;border-radius:8px;cursor:pointer}
    .uird-panel button:hover{background:#2a2a2a}
    .uird-tag{display:inline-block;padding:4px 8px;border:1px solid #444;border-radius:6px;background:#181818;cursor:pointer;margin-right:6px;margin-bottom:6px}
    .uird-subtle{opacity:.65;font-size:11px}

    /* Compact dropdown menu */
    .uird-dd-wrap{position:relative;display:inline-block}
    .uird-dd{position:absolute;top:100%;left:0;min-width:260px;max-height:60vh;overflow:auto;background:#0f0f0f;border:1px solid #333;border-radius:10px;box-shadow:0 10px 26px rgba(0,0,0,.6);padding:8px;z-index:2147483647}
    .uird-dd .group{margin-bottom:8px}
    .uird-dd .group-title{font-size:12px;font-weight:600;opacity:.9;margin:6px 2px}
    .uird-dd .item{display:block;width:100%;text-align:left;margin:4px 0;padding:6px 8px;border:1px solid #444;border-radius:8px;background:#181818;cursor:pointer}
    .uird-dd .item:hover{background:#232323}
    .uird-dd .sep{height:1px;background:#222;margin:6px 0}
  `);

  // =========================
  // Hover chip (unchanged)
  // =========================
  let chip, chipHover = false, clearSelTimer;
  function ensureChip() {
    if (chip) return chip;
    chip = h('div', { class: 'uird-chip', style: { display: 'none' } }, 'Resize');
    chip.addEventListener('mouseenter', () => { chipHover = true; });
    chip.addEventListener('mouseleave', () => { chipHover = false; scheduleClearSelection(); });
    chip.addEventListener('click', () => {
      if (!state.selectedImg && state.lastHoverImg) state.selectedImg = state.lastHoverImg;
      if (state.selectedImg) openPanel();
    });
    document.body.appendChild(chip);
    return chip;
  }
  function positionChipFor(img) {
    const rect = img.getBoundingClientRect();
    const ch = ensureChip();
    const pad = 8;
    let x, y;
    switch (state.options.chipCorner) {
      case 'top-right':    x = scrollX + rect.left + rect.width - 70; y = scrollY + rect.top + pad; break;
      case 'bottom-left':  x = scrollX + rect.left + pad;            y = scrollY + rect.top + rect.height - 30; break;
      case 'bottom-right': x = scrollX + rect.left + rect.width - 70; y = scrollY + rect.top + rect.height - 30; break;
      default:             x = scrollX + rect.left + pad;            y = scrollY + rect.top + pad;
    }
    ch.style.left = `${x}px`;
    ch.style.top  = `${y}px`;
    ch.style.display = 'block';
  }
  function scheduleClearSelection() {
    clearTimeout(clearSelTimer);
    clearSelTimer = setTimeout(() => {
      if (!chipHover) { state.selectedImg = null; ensureChip().style.display = 'none'; }
    }, 250);
  }

  // =========================
  // Draggable panel
  // =========================
  function makeDraggable(panel, handle) {
    let sx, sy, sl, st, dragging = false;
    handle.addEventListener('mousedown', (e) => {
      dragging = true; sx = e.clientX; sy = e.clientY;
      const r = panel.getBoundingClientRect(); sl = r.left; st = r.top; e.preventDefault();
    });
    addEventListener('mousemove', (e) => {
      if (!dragging) return;
      panel.style.left = (sl + (e.clientX - sx)) + 'px';
      panel.style.top  = (st + (e.clientY - sy)) + 'px';
      panel.style.right = 'auto'; panel.style.bottom = 'auto';
    });
    addEventListener('mouseup', () => dragging = false);
  }

  // =========================
  // Persist helpers
  // =========================
  function safeGet(key) {
    try { return GM_getValue(key); } catch { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
  }
  function safeSet(key, value) {
    try { GM_setValue(key, value); } catch { try { localStorage.setItem(key, JSON.stringify(value)); } catch {} }
  }
  function saveOptions() { safeSet(STORE_KEY, state.options); }
  function saveLastFormat(fmt) { safeSet(STORE_LAST_FORMAT, fmt); }

  // =========================
  // Panel UI
  // =========================
  let panel;
  function openPanel() {
    if (panel) { panel.style.display = 'block'; return; }

    // header with dropdown + reset/close
    const ddWrap = h('div', { class: 'uird-dd-wrap' }, [
      button('Platform Presets ▾', (e) => {
        makeDropdown(e.target, (close) => {
          const out = [];
          for (const group of PLATFORM_PRESETS) {
            out.push(h('div', { class: 'group' }, [
              h('div', { class: 'group-title' }, group.title),
              ...group.items.map(it =>
                h('button', {
                  class: 'item', title: `${it.w}×${it.h}`,
                  onclick: () => { setWH(it.w, it.h); close(); }
                }, it.label)
              )
            ]));
            out.push(h('div', { class: 'sep' }));
          }
          out.pop();
          return out;
        });
      })
    ]);

    panel = h('div', { class: 'uird-panel' }, [
      h('div', { class: 'hdr' }, [
        h('div', { class: 'left' }, [
          h('div', {}, 'Image Resizer'),
          ddWrap
        ]),
        h('div', { class: 'right' }, [
          h('button', {
            style: { background: '#2a2a2a', padding: '4px 8px', borderRadius: '6px', border: '1px solid #555' },
            onclick: () => { state.options = { ...DEFAULTS, format: (safeGet(STORE_LAST_FORMAT) || DEFAULTS.format) }; rerenderInputs(); saveOptions(); }
          }, 'Reset'),
          h('button', { style: { background: '#3a3a3a', padding: '4px 8px', borderRadius: '6px', border: '1px solid #555' }, onclick: () => panel.style.display = 'none' }, '✕')
        ])
      ]),

      h('div', { class: 'body' }, [
        // Quick Presets (expanded a bit)
        h('div', { class: 'row-2 uird-subtle' }, 'Quick Presets'),
        chipRow(['3000x3000','4096x4096','2048x2048','1600x1600','1080x1080']),
        chipRow(['5120x2880','3840x2160','2560x1440','2048x1152','1920x1080']),
        chipRow(['1440x2560','1080x1920','1536x2048','1242x2688','1280x720']),

        // Size
        numField('Width', 'width'),
        numField('Height', 'height'),

        // Mode
        selectField('Mode', 'mode', [
          ['contain', 'contain (letterbox)'],
          ['cover',   'cover (crop)'],
          ['stretch', 'stretch (no aspect)']
        ]),

        // BG color
        colorField('BG Color (contain)', 'bgColor'),

        // Format & quality (format sticks)
        selectField('Format', 'format', [
          ['image/jpeg', 'JPEG (.jpg)'],
          ['image/png',  'PNG (.png)'],
          ['image/webp', 'WEBP (.webp)']
        ], (v) => { saveLastFormat(v); return v; }),
        numField('Quality (JPEG/WEBP)', 'quality', { step: '0.01', min: '0', max: '1', isFloat: true }),

        // Filename template + help
        h('div', { class: 'row-2' }, [
          h('label', {}, 'Filename Template'),
          h('input', {
            type: 'text',
            value: state.options.filenameTpl,
            oninput: e => { state.options.filenameTpl = e.target.value; saveOptions(); }
          }),
          h('div', { class: 'uird-subtle row-2' },
            'Tokens: {name} {w} {h} {ext} {ts} {YYYY} {MM} {DD} {hh} {mm} {ss} {site} {title}')
        ]),

        // Behavior toggles
        selectField('Chip Corner', 'chipCorner', [
          ['top-left', 'top-left'],
          ['top-right', 'top-right'],
          ['bottom-left', 'bottom-left'],
          ['bottom-right', 'bottom-right']
        ]),
        selectField('Right-Click trigger', 'useAltRightClick', [
          ['true', 'Alt + Right-Click'],
          ['false', 'Right-Click (no menu)']
        ], v => v === 'true'),

        // Buttons
        h('div', { class: 'btns' }, [
          h('button', { onclick: () => processCurrentImage(false) }, 'Preview (new tab)'),
          h('button', { onclick: () => processCurrentImage(true)  }, 'Download')
        ]),
      ])
    ]);

    document.body.appendChild(panel);
    makeDraggable(panel, panel.querySelector('.hdr'));

    function chipRow(list) {
      return h('div', { class: 'row-2' },
        list.map(sz => h('span', { class: 'uird-tag', onclick: () => applySizeToken(sz) }, sz))
      );
    }

    function applySizeToken(token) {
      const [w, h] = token.split('x').map(n => parseInt(n, 10));
      if (!isFinite(w) || !isFinite(h)) return;
      setWH(w, h);
    }

    function setWH(w, h) {
      state.options.width = w; state.options.height = h; saveOptions(); rerenderInputs();
    }

    function numField(label, key, cfg = {}) {
      const inp = h('input', {
        type: 'number',
        value: state.options[key],
        step: cfg.step || (cfg.isFloat ? '0.01' : '1'),
        min: cfg.min || '0', max: cfg.max || '20000',
        oninput: e => {
          state.options[key] = cfg.isFloat ? clampFloat(e.target.value, 0, 100, state.options[key]) : clampInt(e.target.value, 1, 20000);
          saveOptions();
        }
      });
      return h('div', {}, [ h('label', {}, label), inp ]);
    }
    function selectField(label, key, opts, map = v => v) {
      const sel = h('select', {
        onchange: e => { state.options[key] = map(e.target.value); saveOptions(); }
      }, opts.map(([v, t]) => h('option', { value: String(v), selected: String(state.options[key]) === String(v) }, t)));
      return h('div', {}, [ h('label', {}, label), sel ]);
    }
    function colorField(label, key) {
      const inp = h('input', {
        type: 'color',
        value: state.options[key],
        oninput: e => { state.options[key] = e.target.value; saveOptions(); }
      });
      return h('div', {}, [ h('label', {}, label), inp ]);
    }
    function rerenderInputs() {
      panel.remove();
      panel = null;
      openPanel();
    }
  }

  function clampInt(v, min, max) { v = parseInt(v || 0, 10); if (isNaN(v)) v = min; return Math.max(min, Math.min(max, v)); }
  function clampFloat(v, min, max, fallback) { v = parseFloat(v); if (isNaN(v)) return fallback; return Math.max(min, Math.min(max, v)); }

  // =========================
  // CORS-safe image loading
  // =========================
  function fetchImageArrayBuffer(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'arraybuffer',
        onload: (res) => {
          if (res.status >= 200 && res.status < 300 && res.response) {
            const ct = res.responseHeaders?.match(/content-type:\s*([^\n\r;]+)/i)?.[1]?.trim() || '';
            resolve({ buffer: res.response, contentType: ct });
          } else reject(new Error('Failed to fetch image: ' + res.status));
        },
        onerror: err => reject(err)
      });
    });
  }

  async function loadBitmapFromURL(src) {
    if (/^(data|blob):/i.test(src)) {
      const resp = await fetch(src);
      const blob = await resp.blob();
      const bmp = (self.createImageBitmap) ? await createImageBitmap(blob) : await blobToCanvas(URL.createObjectURL(blob));
      return { bmp, blob, name: guessNameFromURL('image'), type: blob.type || 'image/png' };
    }
    const { buffer, contentType } = await fetchImageArrayBuffer(src);
    const type = contentType || guessMimeFromURL(src) || 'image/png';
    const blob = new Blob([buffer], { type });
    const bmp = (self.createImageBitmap) ? await createImageBitmap(blob) : await blobToCanvas(URL.createObjectURL(blob));
    return { bmp, blob, name: guessNameFromURL(src), type };
  }

  function blobToCanvas(url) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onload = () => {
        const c = document.createElement('canvas');
        c.width = img.naturalWidth; c.height = img.naturalHeight;
        c.getContext('2d').drawImage(img, 0, 0);
        URL.revokeObjectURL(url);
        resolve(c);
      };
      img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
      img.src = url;
    });
  }

  function guessNameFromURL(url) {
    try {
      const u = new URL(url, location.href);
      const base = (u.pathname.split('/').pop() || 'image').split('?')[0].split('#')[0];
      const noExt = base.replace(/\.[a-z0-9]+$/i, '');
      return noExt || 'image';
    } catch { return 'image'; }
  }
  function guessMimeFromURL(url) {
    const ext = (url.split('.').pop() || '').toLowerCase().split('?')[0];
    if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
    if (ext === 'png') return 'image/png';
    if (ext === 'webp') return 'image/webp';
    return '';
  }

  // =========================
  // Resize math
  // =========================
  function computeDrawParams(sw, sh, dw, dh, mode) {
    if (mode === 'stretch') return { sx: 0, sy: 0, sw, sh, dx: 0, dy: 0, dw, dh };
    const sr = sw / sh, dr = dw / dh;

    if (mode === 'contain') {
      let w, h;
      if (sr > dr) { w = dw; h = Math.round(dw / sr); }
      else { h = dh; w = Math.round(dh * sr); }
      const dx = Math.round((dw - w) / 2), dy = Math.round((dh - h) / 2);
      return { sx: 0, sy: 0, sw, sh, dx, dy, dw: w, dh: h };
    }

    // cover
    let cw, ch, cx, cy;
    if (sr > dr) { ch = sh; cw = Math.round(ch * dr); cx = Math.round((sw - cw) / 2); cy = 0; }
    else { cw = sw; ch = Math.round(cw / dr); cx = 0; cy = Math.round((sh - ch) / 2); }
    return { sx: cx, sy: cy, sw: cw, sh: ch, dx: 0, dy: 0, dw, dh };
  }

  // =========================
  // Filename templating
  // =========================
  function formatFilename(tpl, baseName, w, h, ext) {
    const now = new Date();
    const pad = n => String(n).padStart(2, '0');
    const tokens = {
      '{name}': sanitize(baseName || 'image'),
      '{w}': String(w),
      '{h}': String(h),
      '{ext}': ext,
      '{ts}': String(Math.floor(now.getTime() / 1000)),
      '{YYYY}': String(now.getFullYear()),
      '{MM}': pad(now.getMonth() + 1),
      '{DD}': pad(now.getDate()),
      '{hh}': pad(now.getHours()),
      '{mm}': pad(now.getMinutes()),
      '{ss}': pad(now.getSeconds()),
      '{site}': sanitize(location.host || 'site'),
      '{title}': sanitize(document.title || 'untitled')
    };
    let out = tpl;
    for (const [k, v] of Object.entries(tokens)) out = out.split(k).join(v);
    return out;
  }
  function sanitize(s) {
    return (s || '').replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim();
  }

  // =========================
  // Core: process + preview/download (blob-based)
  // =========================
  async function processCurrentImage(doDownload) {
    const img = state.selectedImg || state.lastHoverImg;
    if (!img) return;
    const o = state.options;

    try {
      const src = img.currentSrc || img.src || img.dataset?.src || img.getAttribute('src');
      const { bmp, name } = await loadBitmapFromURL(src);

      const canvas = document.createElement('canvas');
      canvas.width = o.width; canvas.height = o.height;
      const ctx = canvas.getContext('2d');
      ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';

      if (o.mode === 'contain') { ctx.fillStyle = o.bgColor || '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); }

      const p = computeDrawParams(
        bmp.width || bmp.canvas?.width || canvas.width,
        bmp.height || bmp.canvas?.height || canvas.height,
        canvas.width, canvas.height, o.mode
      );
      ctx.drawImage(bmp, p.sx, p.sy, p.sw, p.sh, p.dx, p.dy, p.dw, p.dh);

      const ext = o.format === 'image/png' ? 'png' : (o.format === 'image/webp' ? 'webp' : 'jpg');
      const outBase = formatFilename(o.filenameTpl || DEFAULTS.filenameTpl, name, o.width, o.height, ext);
      const fileName = `${outBase}.${ext}`;

      const blob = await new Promise(res => canvas.toBlob(res, o.format, o.format === 'image/png' ? undefined : o.quality));
      if (!blob) throw new Error('Failed to generate output blob');
      const blobURL = URL.createObjectURL(blob);

      // persist last-used format aggressively
      saveLastFormat(o.format);

      if (doDownload) {
        const a = document.createElement('a');
        a.href = blobURL; a.download = fileName;
        document.body.appendChild(a); a.click(); a.remove();
        setTimeout(() => URL.revokeObjectURL(blobURL), 5000);
      } else {
        const w = window.open('about:blank', '_blank', 'noopener');
        if (w && w.document) {
          w.document.title = fileName;
          w.document.body.style.margin = '0';
          const imgEl = w.document.createElement('img');
          imgEl.src = blobURL;
          imgEl.style.display = 'block';
          imgEl.style.maxWidth = '100%';
          imgEl.style.maxHeight = '100vh';
          w.document.body.appendChild(imgEl);
          w.addEventListener('unload', () => URL.revokeObjectURL(blobURL));
        } else {
          GM_openInTab(blobURL, { active: true, insert: true });
        }
      }
    } catch (err) {
      console.error('[Universal Image Resizer] Error:', err);
      alert('Image resize failed:\n' + (err?.message || err));
    }
  }

  // =========================
  // Hover + Right-Click bindings (unchanged)
  // =========================
  function bindHoverForImages(root = document) {
    root.querySelectorAll('img').forEach(img => {
      if (img.__uirdBound) return;
      img.__uirdBound = true;

      img.addEventListener('mouseenter', () => {
        if (!state.options.showHoverChip) return;
        const r = img.getBoundingClientRect();
        if (r.width < state.options.minImgEdge && r.height < state.options.minImgEdge) return;
        state.selectedImg = img; state.lastHoverImg = img;
        positionChipFor(img);
      });
      img.addEventListener('mouseleave', () => { scheduleClearSelection(); });

      // R to open panel
      img.addEventListener('keydown', (e) => {
        if (e.key === 'r' || e.key === 'R') { state.selectedImg = img; state.lastHoverImg = img; openPanel(); }
      });
      img.tabIndex = img.tabIndex || 0;

      // Alt+RightClick (default) or plain RightClick
      img.addEventListener('contextmenu', (e) => {
        const wantAlt = state.options.useAltRightClick;
        const trigger = wantAlt ? e.altKey : true;
        if (trigger) {
          e.preventDefault();
          state.selectedImg = img; state.lastHoverImg = img;
          openPanel();
        }
      });

      const recalc = () => state.selectedImg === img && positionChipFor(img);
      addEventListener('scroll', recalc, { passive: true });
      addEventListener('resize', recalc);
    });
  }

  const mo = new MutationObserver(muts => {
    for (const m of muts) {
      m.addedNodes && m.addedNodes.forEach(n => {
        if (n.nodeType === 1) {
          if (n.tagName === 'IMG') bindHoverForImages(n.parentNode || document);
          else bindHoverForImages(n);
        }
      });
    }
  });

  function init() {
    ensureChip();
    bindHoverForImages(document);
    mo.observe(document.documentElement, { childList: true, subtree: true });
  }

  if (document.readyState === 'complete' || document.readyState === 'interactive') init();
  else addEventListener('DOMContentLoaded', init);

  // =========================
  // Dropdown helpers
  // =========================
  function makeDropdown(anchorBtn, contentBuilder) {
    // close any open first
    document.querySelectorAll('.uird-dd').forEach(d => d.remove());
    const wrap = anchorBtn.closest('.uird-dd-wrap') || anchorBtn.parentNode;
    const dd = h('div', { class: 'uird-dd' }, contentBuilder(() => dd.remove()));
    wrap.appendChild(dd);

    const onDoc = (e) => {
      if (!dd.contains(e.target) && !anchorBtn.contains(e.target)) {
        dd.remove(); document.removeEventListener('mousedown', onDoc);
      }
    };
    document.addEventListener('mousedown', onDoc);
    const onKey = (e) => { if (e.key === 'Escape') { dd.remove(); document.removeEventListener('keydown', onKey); } };
    document.addEventListener('keydown', onKey);
  }
  function button(text, onclick) { return h('button', { onclick }, text); }

})();