download Tổng hợp các Userscripts

  • Người tạo chủ đề Người tạo chủ đề Fioren
  • Ngày bắt đầu Ngày bắt đầu
1786883174992.webp

Extensions mà chỉ hỏi quyền này đều green-flags phải không sếp @Fioren ?
 
Mn ơi sao giờ github bị gì hong vô dc nó đang bảo trì hay sao dị ạ
Sáng còn mở dc mà giờ ngủm
Github và các domain khác của nó, cách cấu hình hơi phức tạp so với các domain khác, chưa kể các cụm server của họ đôi lúc quá tải ... tạo ra rất nhiều tình huống ngẫu nhiên, kiểu có user chạy bình thường, có user lại chạy lỗi

Ở domain gốc, IP trả về tùy thuộc vào ECS, user tại VN, truy cập thông thường, trả về server tại Singapore

2026-08-17_08-53-51.webp


Dùng ECS US, trả về IP US

2026-08-17_08-55-10.webp


  • Nếu cụm Sing sập -> user tại VN truy cập thấy sập, nhưng user tại US thấy bình thường
Ở các domain phụ, ví dụ raw.githubusercontent.com, mặc định trả về server US, nhưng tỷ lệ lỗi ngẫu nhiên còn cao hơn

2026-08-17_08-57-18.webp


1 DNS thông thường khi phân giải, mặc định trả về ngẫu nhiên 4 IP, chú ý thứ tự trả về là ngẫu nhiên, tùy thời điểm phân giải

Nếu 185.199.111.133 sập, user nào dùng theo IP đó, sẽ .... không truy cập được, ở trình duyệt, thường nó sẽ có cơ chế load lại, kiểu truy cập 185.199.111.133 vài lần ko được, nó dùng IP tiếp theo, nhưng trên cmd, powershell ... thường nó cache khá lâu, truy cập IP đầu tiên ko được là phải chờ cho tới khi Windows tự clear cache, rất phiền

Thi thoảng github ngáo ngáo, vào githubstatus.com thấy báo đang sập thì hiểu là sập thực sự, còn tại githubstatus.com bình thường, mà phía user truy cập không được thì đổi sang DNS, VPS khác chạy để nó trả về IP khác cũng được

Tình huống cụ thể, tôi dùng nhà mạng VNPT, dùng DNS nào cũng thấy chập chờn khi truy cập github và các domain phụ của nó, cách đây ~ 6 tháng, dùng https://dns.mullvad.net/dns-query thì tỷ lệ lỗi giảm đi tới 99% ... rất ảo, vì IP nó trả về y chang Cloudflare DNS, mà dùng Cloudflare thì cứ lâu lâu thấy truy cập github không được :]] mà loanh quanh 1 tháng gần đây, dùng mullvad truy cập github lại chập chờn, đổi sang Cloudflare lại thấy ổn định :]]
 
Đây fen, chỉnh tên miền sẽ áp dụng, để trống là mặc định tất cả tên miền, và chất lượng ảnh trong Tampermonkey nữa nhé.

Update lại xíu, thêm tính năng copy ảnh, mở ảnh ở tab mới, chỉnh sửa ảnh, tìm kiếm ảnh.

JavaScript:
// ==UserScript==
// @name         Image Format Saver (Custom Right-Click Menu)
// @namespace     kien.tools
// @version      3.6
// @description  Right-click ảnh -> Mở tab mới | Copy ảnh/link | Lưu định dạng (PNG/JPG/WEBP/AVIF) | Chỉnh sửa ảnh, tìm kiếm ảnh (JPG->Google, còn lại->Yandex, tự bóc proxy + check Content-Type thật); dialog chọn nơi lưu, whitelist domain và chất lượng ảnh qua menu Tampermonkey
// @author       Kien
// @match        *://*/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @connect      *
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // Tampermonkey chạy trong sandbox riêng, nhiều API của trình duyệt (showSaveFilePicker,
  // Clipboard API, ClipboardItem...) không được proxy qua window của sandbox dù trình duyệt
  // có hỗ trợ. Dùng unsafeWindow (window thật của trang) để truy cập trực tiếp các API này.
  const REAL_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

  // ================== CẤU HÌNH DOMAIN WHITELIST ==================
  const allowedDomains = GM_getValue('allowedDomains', []);

  function isDomainAllowed() {
    if (!allowedDomains.length) return true; // để trống = cho phép tất cả
    const host = location.hostname;
    return allowedDomains.some((pattern) => {
      pattern = (pattern || '').trim();
      if (!pattern) return false;
      if (pattern.startsWith('*.')) {
        const base = pattern.slice(2);
        return host === base || host.endsWith('.' + base);
      }
      return host === pattern || host.endsWith('.' + pattern);
    });
  }

  function configureDomains() {
    const current = GM_getValue('allowedDomains', []);

    const overlay = document.createElement('div');
    Object.assign(overlay.style, {
      position: 'fixed', inset: '0', zIndex: 2147483647,
      background: 'rgba(0,0,0,.45)', display: 'flex',
      alignItems: 'flex-start', justifyContent: 'center',
      paddingTop: '5vh', fontFamily: 'system-ui, sans-serif',
    });

    const panel = document.createElement('div');
    Object.assign(panel.style, {
      background: '#fff', color: '#222', width: '620px', maxWidth: '92vw',
      maxHeight: '85vh', borderRadius: '8px', boxShadow: '0 10px 40px rgba(0,0,0,.35)',
      display: 'flex', flexDirection: 'column', overflow: 'hidden',
    });

    const header = document.createElement('div');
    header.textContent = '⚙️ Cấu hình domain cho phép chạy script';
    Object.assign(header.style, {
      padding: '16px 20px', fontSize: '17px', fontWeight: '600',
      borderBottom: '1px solid #eee',
    });

    const subLabel = document.createElement('div');
    subLabel.textContent = 'Domain được phép chạy script (hỗ trợ wildcard *. ở đầu, vd: *.example.com). Để trống toàn bộ = chạy trên mọi trang:';
    Object.assign(subLabel.style, { padding: '12px 20px 4px', fontSize: '13px', color: '#555' });

    const listWrap = document.createElement('div');
    Object.assign(listWrap.style, { overflowY: 'auto', padding: '8px 20px', flex: '1' });

    const table = document.createElement('div');
    table.style.width = '100%';

    function makeRow(index, value) {
      const row = document.createElement('div');
      Object.assign(row.style, {
        display: 'flex', alignItems: 'center', gap: '10px',
        padding: '6px 0', borderBottom: '1px solid #f0f0f0',
      });

      const num = document.createElement('div');
      num.textContent = String(index);
      Object.assign(num.style, {
        width: '28px', textAlign: 'center', color: '#888', fontSize: '13px', flexShrink: '0',
      });

      const input = document.createElement('input');
      input.type = 'text';
      input.value = value || '';
      input.placeholder = 'vd: example.com hoặc *.example.com';
      Object.assign(input.style, {
        flex: '1', padding: '7px 10px', border: '1px solid #ccc',
        borderRadius: '5px', fontSize: '14px',
      });

      const removeBtn = document.createElement('button');
      removeBtn.textContent = '✕';
      Object.assign(removeBtn.style, {
        border: 'none', background: 'transparent', color: '#c33',
        cursor: 'pointer', fontSize: '15px', flexShrink: '0', padding: '4px 6px',
      });
      removeBtn.addEventListener('click', () => {
        row.remove();
        renumberRows();
      });

      row.appendChild(num);
      row.appendChild(input);
      row.appendChild(removeBtn);
      return row;
    }

    function renumberRows() {
      [...table.children].forEach((row, i) => {
        row.firstChild.textContent = String(i + 1);
      });
    }

    (current.length ? current : ['']).forEach((d, i) => table.appendChild(makeRow(i + 1, d)));

    listWrap.appendChild(table);

    const addBtn = document.createElement('button');
    addBtn.textContent = '+ Thêm domain';
    Object.assign(addBtn.style, {
      margin: '10px 20px 0', alignSelf: 'flex-start', border: '1px dashed #999',
      background: 'transparent', borderRadius: '5px', padding: '6px 12px',
      cursor: 'pointer', fontSize: '13px', color: '#444',
    });
    addBtn.addEventListener('click', () => {
      table.appendChild(makeRow(table.children.length + 1, ''));
      listWrap.scrollTop = listWrap.scrollHeight;
    });

    const footer = document.createElement('div');
    Object.assign(footer.style, {
      display: 'flex', justifyContent: 'flex-end', gap: '10px',
      padding: '14px 20px', borderTop: '1px solid #eee',
    });

    const cancelBtn = document.createElement('button');
    cancelBtn.textContent = 'Hủy';
    Object.assign(cancelBtn.style, {
      padding: '8px 16px', borderRadius: '5px', border: '1px solid #ccc',
      background: '#f5f5f5', cursor: 'pointer', fontSize: '14px',
    });
    cancelBtn.addEventListener('click', () => overlay.remove());

    const saveBtn = document.createElement('button');
    saveBtn.textContent = 'Lưu';
    Object.assign(saveBtn.style, {
      padding: '8px 16px', borderRadius: '5px', border: 'none',
      background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600',
    });
    saveBtn.addEventListener('click', () => {
      const domains = [...table.querySelectorAll('input')]
        .map((inp) => inp.value.trim())
        .filter(Boolean);
      GM_setValue('allowedDomains', domains);
      overlay.remove();
      alert(
        'Đã lưu danh sách domain. Vui lòng RELOAD lại trang để áp dụng.\n\n' +
          (domains.length ? domains.join('\n') : '(cho phép chạy trên mọi trang)')
      );
    });

    footer.appendChild(cancelBtn);
    footer.appendChild(saveBtn);

    panel.appendChild(header);
    panel.appendChild(subLabel);
    panel.appendChild(listWrap);
    panel.appendChild(addBtn);
    panel.appendChild(footer);
    overlay.appendChild(panel);

    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) overlay.remove();
    });
    document.addEventListener(
      'keydown',
      function escClose(e) {
        if (e.key === 'Escape') {
          overlay.remove();
          document.removeEventListener('keydown', escClose);
        }
      }
    );

    document.body.appendChild(overlay);
  }

  GM_registerMenuCommand('⚙️ Cấu hình domain cho phép chạy script', configureDomains);

  // ================== CẤU HÌNH CHẤT LƯỢNG ẢNH (%) ==================
  const DEFAULT_QUALITY = { jpg: 92, webp: 92, avif: 80 };

  function getQualityMap() {
    const stored = GM_getValue('formatQuality', {});
    return Object.assign({}, DEFAULT_QUALITY, stored);
  }

  // Trả về quality dạng 0-1 để truyền vào canvas.toBlob(). PNG không dùng quality.
  function getQuality(ext) {
    const map = getQualityMap();
    const val = map[ext];
    const percent = typeof val === 'number' && val >= 1 && val <= 100 ? val : DEFAULT_QUALITY[ext] || 90;
    return percent / 100;
  }

  function configureQuality() {
    const current = getQualityMap();
    const editableFormats = [
      { ext: 'jpg', label: 'JPG' },
      { ext: 'webp', label: 'WEBP' },
      { ext: 'avif', label: 'AVIF' },
    ];

    const overlay = document.createElement('div');
    Object.assign(overlay.style, {
      position: 'fixed', inset: '0', zIndex: 2147483647,
      background: 'rgba(0,0,0,.45)', display: 'flex',
      alignItems: 'flex-start', justifyContent: 'center',
      paddingTop: '5vh', fontFamily: 'system-ui, sans-serif',
    });

    const panel = document.createElement('div');
    Object.assign(panel.style, {
      background: '#fff', color: '#222', width: '460px', maxWidth: '92vw',
      borderRadius: '8px', boxShadow: '0 10px 40px rgba(0,0,0,.35)',
      display: 'flex', flexDirection: 'column', overflow: 'hidden',
    });

    const header = document.createElement('div');
    header.textContent = '🎚️ Cấu hình chất lượng ảnh khi lưu';
    Object.assign(header.style, {
      padding: '16px 20px', fontSize: '17px', fontWeight: '600',
      borderBottom: '1px solid #eee',
    });

    const subLabel = document.createElement('div');
    subLabel.textContent = 'PNG luôn là lossless nên không có chất lượng để chỉnh. Kéo thanh trượt hoặc gõ số (1-100):';
    Object.assign(subLabel.style, { padding: '12px 20px 4px', fontSize: '13px', color: '#555' });

    const body = document.createElement('div');
    Object.assign(body.style, { padding: '8px 20px 4px' });

    const numberInputs = {};

    editableFormats.forEach(({ ext, label }) => {
      const row = document.createElement('div');
      Object.assign(row.style, {
        display: 'flex', alignItems: 'center', gap: '12px',
        padding: '10px 0', borderBottom: '1px solid #f0f0f0',
      });

      const nameEl = document.createElement('div');
      nameEl.textContent = label;
      Object.assign(nameEl.style, { width: '52px', fontWeight: '600', fontSize: '14px', flexShrink: '0' });

      const range = document.createElement('input');
      range.type = 'range';
      range.min = '1';
      range.max = '100';
      range.value = String(current[ext] ?? DEFAULT_QUALITY[ext]);
      Object.assign(range.style, { flex: '1' });

      const number = document.createElement('input');
      number.type = 'number';
      number.min = '1';
      number.max = '100';
      number.value = String(current[ext] ?? DEFAULT_QUALITY[ext]);
      Object.assign(number.style, {
        width: '56px', padding: '5px 6px', border: '1px solid #ccc',
        borderRadius: '5px', fontSize: '14px', textAlign: 'center', flexShrink: '0',
      });

      const percentEl = document.createElement('span');
      percentEl.textContent = '%';
      Object.assign(percentEl.style, { fontSize: '13px', color: '#666', flexShrink: '0' });

      range.addEventListener('input', () => (number.value = range.value));
      number.addEventListener('input', () => {
        let v = parseInt(number.value, 10);
        if (isNaN(v)) return;
        v = Math.min(100, Math.max(1, v));
        range.value = String(v);
      });

      numberInputs[ext] = number;

      row.appendChild(nameEl);
      row.appendChild(range);
      row.appendChild(number);
      row.appendChild(percentEl);
      body.appendChild(row);
    });

    const footer = document.createElement('div');
    Object.assign(footer.style, {
      display: 'flex', justifyContent: 'flex-end', gap: '10px',
      padding: '14px 20px', borderTop: '1px solid #eee',
    });

    const cancelBtn = document.createElement('button');
    cancelBtn.textContent = 'Hủy';
    Object.assign(cancelBtn.style, {
      padding: '8px 16px', borderRadius: '5px', border: '1px solid #ccc',
      background: '#f5f5f5', cursor: 'pointer', fontSize: '14px',
    });
    cancelBtn.addEventListener('click', () => overlay.remove());

    const saveBtn = document.createElement('button');
    saveBtn.textContent = 'Lưu';
    Object.assign(saveBtn.style, {
      padding: '8px 16px', borderRadius: '5px', border: 'none',
      background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600',
    });
    saveBtn.addEventListener('click', () => {
      const newMap = {};
      editableFormats.forEach(({ ext }) => {
        let v = parseInt(numberInputs[ext].value, 10);
        if (isNaN(v)) v = DEFAULT_QUALITY[ext];
        newMap[ext] = Math.min(100, Math.max(1, v));
      });
      GM_setValue('formatQuality', newMap);
      overlay.remove();
    });

    footer.appendChild(cancelBtn);
    footer.appendChild(saveBtn);

    panel.appendChild(header);
    panel.appendChild(subLabel);
    panel.appendChild(body);
    panel.appendChild(footer);
    overlay.appendChild(panel);

    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) overlay.remove();
    });
    document.addEventListener('keydown', function escClose(e) {
      if (e.key === 'Escape') {
        overlay.remove();
        document.removeEventListener('keydown', escClose);
      }
    });

    document.body.appendChild(overlay);
  }

  GM_registerMenuCommand('🎚️ Cấu hình chất lượng ảnh khi lưu', configureQuality);

  if (!isDomainAllowed()) {
    return; // domain hiện tại không nằm trong whitelist -> không làm gì cả
  }

  // ================== FIX ẢNH ĐEN CHO CANVAS WEBGL ==================
  // (vd: Real3DFlipBook / Three.js dùng preserveDrawingBuffer:false mặc định)
  const _origGetContext = HTMLCanvasElement.prototype.getContext;
  HTMLCanvasElement.prototype.getContext = function (type, attrs) {
    if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
      attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true });
    }
    return _origGetContext.call(this, type, attrs);
  };

  // ================== DANH SÁCH ĐỊNH DẠNG ==================
  const FORMATS = [
    { label: 'Lưu PNG', mime: 'image/png', ext: 'png' },
    { label: 'Lưu JPG', mime: 'image/jpeg', ext: 'jpg' },
    { label: 'Lưu WEBP', mime: 'image/webp', ext: 'webp' },
    { label: 'Lưu AVIF', mime: 'image/avif', ext: 'avif' },
  ];


  let menuEl = null;

  function removeMenu() {
    if (menuEl) {
      menuEl.remove();
      menuEl = null;
    }
    document.removeEventListener('click', removeMenu, true);
    document.removeEventListener('scroll', removeMenu, true);
    document.removeEventListener('keydown', onKeyDown, true);
  }

  function onKeyDown(e) {
    if (e.key === 'Escape') removeMenu();
  }

  // ================== DIALOG CHỌN NƠI LƯU (native "Save As") ==================
  async function pickSaveHandle(filename, mime) {
    const picker = REAL_WINDOW.showSaveFilePicker;
    if (!picker) return null; // trình duyệt không hỗ trợ -> fallback
    try {
      const ext = filename.split('.').pop();
      const handle = await picker.call(REAL_WINDOW, {
        suggestedName: filename,
        types: [{ description: 'Image file', accept: { [mime]: ['.' + ext] } }],
      });
      return handle;
    } catch (err) {
      if (err.name === 'AbortError') return 'cancelled';
      console.warn('[ImageFormatSaver] showSaveFilePicker lỗi:', err);
      return null;
    }
  }

  async function writeBlob(blob, filename, handle) {
    if (handle) {
      try {
        const writable = await handle.createWritable();
        await writable.write(blob);
        await writable.close();
        return;
      } catch (err) {
        console.warn('[ImageFormatSaver] Ghi file qua File System Access API lỗi, dùng fallback:', err);
      }
    }
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
  }

  function suggestedFileName(img, ext) {
    let base = 'image';
    try {
      const u = new URL(img.src, location.href);
      const last = u.pathname.split('/').pop();
      if (last) base = last.replace(/\.[a-zA-Z0-9]+$/, '');
    } catch (e) {}
    return base + '.' + ext;
  }

  // ================== LƯU TỪ <img> ==================
  function fallbackDownloadOriginal(img, handle) {
    GM_xmlhttpRequest({
      method: 'GET',
      url: img.src,
      responseType: 'blob',
      onload: function (res) {
        const blob = res.response;
        let ext = 'jpg';
        const m = img.src.match(/\.([a-zA-Z0-9]+)(?:\?|#|$)/);
        if (m) ext = m[1];
        writeBlob(blob, suggestedFileName(img, ext), handle);
      },
      onerror: function () {
        alert('Không tải được ảnh gốc (lỗi mạng hoặc bị chặn).');
      },
    });
  }

  function saveImageAs(img, fmt, handle) {
    if (!img) return;

    const doConvert = (imageEl) => {
      const canvas = document.createElement('canvas');
      canvas.width = imageEl.naturalWidth || imageEl.width;
      canvas.height = imageEl.naturalHeight || imageEl.height;
      const ctx = canvas.getContext('2d');
      try {
        ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height);
      } catch (err) {
        console.warn('[ImageFormatSaver] drawImage lỗi:', err);
        fallbackDownloadOriginal(img, handle);
        return;
      }

      canvas.toBlob(
        (blob) => {
          if (!blob) {
            console.warn('[ImageFormatSaver] toBlob null — canvas có thể bị taint (CORS).');
            fallbackDownloadOriginal(img, handle);
            return;
          }
          if (fmt.mime !== 'image/png' && blob.type !== fmt.mime) {
            alert(
              'Trình duyệt không hỗ trợ encode ' + fmt.ext.toUpperCase() + ' qua canvas. Thử định dạng khác (PNG luôn hỗ trợ).'
            );
            return;
          }
          writeBlob(blob, suggestedFileName(img, fmt.ext), handle);
        },
        fmt.mime,
        getQuality(fmt.ext)
      );
    };

    if (img.crossOrigin === 'anonymous' || img.src.startsWith(location.origin) || img.src.startsWith('data:')) {
      doConvert(img);
      return;
    }

    const testImg = new Image();
    testImg.crossOrigin = 'anonymous';
    testImg.onload = () => doConvert(testImg);
    testImg.onerror = () => {
      console.warn('[ImageFormatSaver] Load lại ảnh CORS thất bại, fallback tải gốc.');
      fallbackDownloadOriginal(img, handle);
    };
    testImg.src = img.src;
  }

  // ================== LƯU TỪ <canvas> (vd: WebGL flipbook) ==================
  function saveImageAsCanvas(canvas, fmt, handle) {
    try {
      canvas.toBlob(
        (blob) => {
          if (!blob) {
            alert('Không tạo được ảnh từ canvas này (canvas rỗng hoặc bị taint).');
            return;
          }
          if (fmt.mime !== 'image/png' && blob.type !== fmt.mime) {
            alert('Trình duyệt không hỗ trợ encode ' + fmt.ext.toUpperCase() + ' từ canvas. Thử định dạng khác.');
            return;
          }
          writeBlob(blob, 'image.' + fmt.ext, handle);
        },
        fmt.mime,
        getQuality(fmt.ext)
      );
    } catch (err) {
      console.warn('[ImageFormatSaver] Canvas tainted:', err);
      alert(
        'Canvas này chứa dữ liệu ảnh cross-origin nên trình duyệt chặn không cho đọc pixel để lưu. Không có cách lách giới hạn bảo mật này.'
      );
    }
  }

  // ================== COPY ẢNH / COPY LINK ẢNH ==================
  function imageElementToBlob(img, mime, quality) {
    return new Promise((resolve, reject) => {
      const doConvert = (imageEl) => {
        const canvas = document.createElement('canvas');
        canvas.width = imageEl.naturalWidth || imageEl.width;
        canvas.height = imageEl.naturalHeight || imageEl.height;
        const ctx = canvas.getContext('2d');
        try {
          ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height);
        } catch (err) {
          reject(err);
          return;
        }
        canvas.toBlob(
          (blob) => {
            if (blob) resolve(blob);
            else reject(new Error('toBlob trả về null'));
          },
          mime,
          quality
        );
      };

      if (img.crossOrigin === 'anonymous' || img.src.startsWith(location.origin) || img.src.startsWith('data:')) {
        doConvert(img);
        return;
      }

      const testImg = new Image();
      testImg.crossOrigin = 'anonymous';
      testImg.onload = () => doConvert(testImg);
      testImg.onerror = (err) => reject(err);
      testImg.src = img.src;
    });
  }

  function imageElementToPngBlob(img) {
    return imageElementToBlob(img, 'image/png');
  }

  function canvasToPngBlob(canvas) {
    return new Promise((resolve, reject) => {
      try {
        canvas.toBlob((blob) => {
          if (blob) resolve(blob);
          else reject(new Error('toBlob trả về null'));
        }, 'image/png');
      } catch (err) {
        reject(err);
      }
    });
  }

  async function copyBlobToClipboard(blob) {
    const clipboard = REAL_WINDOW.navigator && REAL_WINDOW.navigator.clipboard;
    const ClipboardItemCtor = REAL_WINDOW.ClipboardItem;
    if (!clipboard || !clipboard.write || !ClipboardItemCtor) {
      throw new Error('Trình duyệt không hỗ trợ Clipboard API để copy ảnh.');
    }
    await clipboard.write([new ClipboardItemCtor({ [blob.type]: blob })]);
  }

  async function copyImageToClipboard(img) {
    try {
      const blob = await imageElementToPngBlob(img);
      await copyBlobToClipboard(blob);
    } catch (err) {
      console.warn('[ImageFormatSaver] Copy ảnh lỗi:', err);
      alert(
        'Không copy được ảnh vào clipboard. Có thể do ảnh bị chặn CORS (cross-origin) hoặc trình duyệt từ chối quyền clipboard.'
      );
    }
  }

  async function copyCanvasToClipboard(canvas) {
    try {
      const blob = await canvasToPngBlob(canvas);
      await copyBlobToClipboard(blob);
    } catch (err) {
      console.warn('[ImageFormatSaver] Copy ảnh (canvas) lỗi:', err);
      alert(
        'Không copy được ảnh vào clipboard. Canvas có thể bị taint do dữ liệu cross-origin, hoặc trình duyệt từ chối quyền clipboard.'
      );
    }
  }

  async function copyImageLink(img) {
    const url = img && img.src ? img.src : '';
    if (!url) {
      alert('Không tìm thấy link ảnh.');
      return;
    }
    try {
      const clipboard = REAL_WINDOW.navigator && REAL_WINDOW.navigator.clipboard;
      if (!clipboard || !clipboard.writeText) throw new Error('Không có Clipboard API');
      await clipboard.writeText(url);
    } catch (err) {
      console.warn('[ImageFormatSaver] Copy link ảnh lỗi:', err);
      alert('Không copy được link ảnh vào clipboard.');
    }
  }


  // ================== BÓC URL THẬT KHỎI PROXY (vd: voz.vn/proxy.php?image=...&hash=...) ==================
  // Nhiều forum/site dùng 1 script proxy để chống hotlink, wrap URL ảnh gốc vào 1 query param.
  // Reverse-image-search cần URL ảnh THẬT để Google có thể fetch được, nên phải bóc ra trước.
  function unwrapProxyUrl(url) {
    try {
      const u = new URL(url, location.href);
      const candidateKeys = ['image', 'img', 'url', 'src', 'target', 'u', 'imgurl', 'imageurl', 'dest'];
      for (const key of candidateKeys) {
        const val = u.searchParams.get(key); // URLSearchParams tự decode sẵn
        if (val && /^https?:\/\//i.test(val)) {
          return val;
        }
      }
    } catch (err) {
      console.warn('[ImageFormatSaver] unwrapProxyUrl lỗi:', err);
    }
    return url; // không nhận diện được dạng proxy -> giữ nguyên
  }

  // ================== TOAST NHỎ TỰ TẮT (không cần bấm OK) ==================
  function showToast(message, duration) {
    const toast = document.createElement('div');
    toast.textContent = message;
    Object.assign(toast.style, {
      position: 'fixed',
      bottom: '30px',
      left: '50%',
      transform: 'translateX(-50%)',
      background: '#222',
      color: '#fff',
      padding: '10px 16px',
      borderRadius: '6px',
      fontSize: '13px',
      lineHeight: '1.4',
      fontFamily: 'system-ui, sans-serif',
      zIndex: 2147483647,
      boxShadow: '0 4px 14px rgba(0,0,0,.4)',
      maxWidth: '90vw',
      textAlign: 'center',
      opacity: '0',
      transition: 'opacity .25s ease',
      pointerEvents: 'none',
    });
    document.body.appendChild(toast);
    requestAnimationFrame(() => {
      toast.style.opacity = '1';
    });
    setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 300);
    }, duration || 3500);
  }

  // ================== TÌM KIẾM ẢNH TRÊN WEB (reverse image search) ==================
  // Bóc URL thật ra khỏi proxy (nếu có, vd voz.vn/proxy.php?image=...), rồi kiểm tra
  // Content-Type THẬT của ảnh qua GM_xmlhttpRequest (đáng tin cậy hơn đoán qua đuôi file,
  // vì URL kiểu proxy/attachment thường không có đuôi rõ ràng). JPG -> tìm bằng Google.
  // Các định dạng còn lại (PNG/WEBP/AVIF/GIF...) -> tìm bằng Yandex (đọc được WebP qua URL,
  // Google thì không).
  function getContentType(url) {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: 'HEAD',
        url: url,
        timeout: 4000,
        onload: (res) => {
          const headers = res.responseHeaders || '';
          const match = headers.match(/content-type:\s*([^\r\n;]+)/i);
          resolve(match ? match[1].trim().toLowerCase() : '');
        },
        onerror: () => resolve(''),
        ontimeout: () => resolve(''),
      });
    });
  }

  async function searchImageOnWeb(img) {
    const rawUrl = img && img.src ? img.src : '';
    if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) {
      showToast('Không thể tìm kiếm ảnh này (không có URL công khai).');
      return;
    }

    const url = unwrapProxyUrl(rawUrl);

    // Đuôi file rõ ràng là jpg -> khỏi cần chờ request, dùng Google luôn cho nhanh
    let isJpg = /\.jpe?g(\?|#|$)/i.test(url);
    if (!isJpg) {
      const contentType = await getContentType(url);
      isJpg = contentType.indexOf('image/jpeg') !== -1 || contentType.indexOf('image/jpg') !== -1;
    }

    const searchUrl = isJpg
      ? 'https://www.google.com/searchbyimage?image_url=' + encodeURIComponent(url) + '&safe=off'
      : 'https://yandex.com/images/search?rpt=imageview&url=' + encodeURIComponent(url);
    REAL_WINDOW.open(searchUrl, '_blank', 'noopener');
  }

  async function searchCanvasOnWeb(canvas) {
    let blob;
    try {
      blob = await canvasToPngBlob(canvas);
    } catch (err) {
      console.warn('[ImageFormatSaver] Không lấy được ảnh canvas để tìm kiếm:', err);
      showToast('Không thể tìm kiếm ảnh này (canvas có thể bị taint do cross-origin).');
      return;
    }
    try {
      await copyBlobToClipboard(blob);
      REAL_WINDOW.open('https://images.google.com/', '_blank', 'noopener');
      showToast('Đã copy ảnh vào clipboard - dán Ctrl+V vào ô tìm kiếm Google Images.', 4500);
    } catch (err) {
      console.warn('[ImageFormatSaver] Copy ảnh canvas để search lỗi:', err);
      showToast('Không copy được ảnh vào clipboard để tìm kiếm.');
    }
  }

  // ================== MỞ HÌNH ẢNH TRONG TAB MỚI ==================
  function openImageInNewTab(img) {
    const url = img && img.src ? img.src : '';
    if (!url || url.startsWith('data:') || url.startsWith('blob:')) {
      alert('Ảnh này không có URL trực tiếp để mở tab mới.');
      return;
    }
    REAL_WINDOW.open(url, '_blank', 'noopener');
  }

  // ================== CHỈNH SỬA ẢNH (editor tự viết, không phải tool gốc của Edge) ==================
  function loadDrawableCanvas(target, isCanvasSource) {
    return new Promise((resolve, reject) => {
      const cloneFrom = (source) => {
        const c = document.createElement('canvas');
        c.width = source.naturalWidth || source.width;
        c.height = source.naturalHeight || source.height;
        const ctx = c.getContext('2d');
        try {
          ctx.drawImage(source, 0, 0, c.width, c.height);
        } catch (err) {
          reject(err);
          return;
        }
        resolve(c);
      };

      if (isCanvasSource) {
        cloneFrom(target);
        return;
      }

      if (target.crossOrigin === 'anonymous' || target.src.startsWith(location.origin) || target.src.startsWith('data:')) {
        cloneFrom(target);
        return;
      }

      const testImg = new Image();
      testImg.crossOrigin = 'anonymous';
      testImg.onload = () => cloneFrom(testImg);
      testImg.onerror = (err) => reject(err);
      testImg.src = target.src;
    });
  }

  async function openImageEditor(target, isCanvasSource) {
    let workCanvas;
    try {
      workCanvas = await loadDrawableCanvas(target, isCanvasSource);
    } catch (err) {
      console.warn('[ImageFormatSaver] Không load được ảnh để sửa:', err);
      alert('Không thể chỉnh sửa ảnh này (có thể bị chặn CORS hoặc canvas bị taint).');
      return;
    }

    const history = [workCanvas.toDataURL('image/png')];

    const overlay = document.createElement('div');
    Object.assign(overlay.style, {
      position: 'fixed', inset: '0', zIndex: 2147483647,
      background: 'rgba(0,0,0,.6)', display: 'flex',
      alignItems: 'center', justifyContent: 'center',
      fontFamily: 'system-ui, sans-serif',
    });

    const panel = document.createElement('div');
    Object.assign(panel.style, {
      background: '#1e1e1e', color: '#eee', borderRadius: '8px',
      boxShadow: '0 10px 40px rgba(0,0,0,.5)', display: 'flex',
      flexDirection: 'column', maxWidth: '95vw', maxHeight: '92vh', overflow: 'hidden',
    });

    const header = document.createElement('div');
    header.textContent = '✏️ Chỉnh sửa ảnh';
    Object.assign(header.style, {
      padding: '12px 18px', fontSize: '16px', fontWeight: '600', borderBottom: '1px solid #333',
    });

    const toolbar = document.createElement('div');
    Object.assign(toolbar.style, {
      display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
      padding: '10px 14px', borderBottom: '1px solid #333', background: '#252525',
    });

    const canvasScrollWrap = document.createElement('div');
    Object.assign(canvasScrollWrap.style, {
      overflow: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: '#111', flex: '1', padding: '14px', position: 'relative',
    });

    const displayCanvas = document.createElement('canvas');
    Object.assign(displayCanvas.style, {
      maxWidth: '100%', maxHeight: '70vh', background: '#fff', cursor: 'default',
      boxShadow: '0 2px 10px rgba(0,0,0,.5)',
    });

    const footer = document.createElement('div');
    Object.assign(footer.style, {
      display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      gap: '10px', padding: '12px 18px', borderTop: '1px solid #333', background: '#252525',
    });

    function btnStyle(el) {
      Object.assign(el.style, {
        padding: '6px 10px', borderRadius: '5px', border: '1px solid #444',
        background: '#333', color: '#eee', cursor: 'pointer', fontSize: '13px',
      });
      el.addEventListener('mouseenter', () => (el.style.background = '#444'));
      el.addEventListener('mouseleave', () => (el.style.background = '#333'));
    }

    function makeButton(label, onClick) {
      const b = document.createElement('button');
      b.textContent = label;
      btnStyle(b);
      b.addEventListener('click', onClick);
      return b;
    }

    function redraw() {
      displayCanvas.width = workCanvas.width;
      displayCanvas.height = workCanvas.height;
      const ctx = displayCanvas.getContext('2d');
      ctx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
      ctx.drawImage(workCanvas, 0, 0);
    }

    function pushHistory() {
      history.push(workCanvas.toDataURL('image/png'));
      if (history.length > 20) history.shift();
    }

    function restoreFromDataUrl(dataUrl, cb) {
      const img = new Image();
      img.onload = () => {
        workCanvas.width = img.width;
        workCanvas.height = img.height;
        const ctx = workCanvas.getContext('2d');
        ctx.clearRect(0, 0, workCanvas.width, workCanvas.height);
        ctx.drawImage(img, 0, 0);
        redraw();
        if (cb) cb();
      };
      img.src = dataUrl;
    }

    function undo() {
      if (history.length <= 1) return;
      history.pop();
      restoreFromDataUrl(history[history.length - 1]);
    }

    function reset() {
      const first = history[0];
      history.length = 1;
      restoreFromDataUrl(first);
    }

    function rotate(deg) {
      const rad = (deg * Math.PI) / 180;
      const w = workCanvas.width;
      const h = workCanvas.height;
      const newCanvas = document.createElement('canvas');
      newCanvas.width = h;
      newCanvas.height = w;
      const ctx = newCanvas.getContext('2d');
      ctx.translate(newCanvas.width / 2, newCanvas.height / 2);
      ctx.rotate(rad);
      ctx.drawImage(workCanvas, -w / 2, -h / 2);
      workCanvas.width = newCanvas.width;
      workCanvas.height = newCanvas.height;
      const wctx = workCanvas.getContext('2d');
      wctx.clearRect(0, 0, workCanvas.width, workCanvas.height);
      wctx.drawImage(newCanvas, 0, 0);
      pushHistory();
      redraw();
    }

    function flip(horizontal) {
      const w = workCanvas.width;
      const h = workCanvas.height;
      const newCanvas = document.createElement('canvas');
      newCanvas.width = w;
      newCanvas.height = h;
      const ctx = newCanvas.getContext('2d');
      if (horizontal) {
        ctx.translate(w, 0);
        ctx.scale(-1, 1);
      } else {
        ctx.translate(0, h);
        ctx.scale(1, -1);
      }
      ctx.drawImage(workCanvas, 0, 0);
      const wctx = workCanvas.getContext('2d');
      wctx.clearRect(0, 0, w, h);
      wctx.drawImage(newCanvas, 0, 0);
      pushHistory();
      redraw();
    }

    let mode = null; // null | 'draw' | 'crop'
    let brushColor = '#ff0000';
    let brushSize = 4;
    let drawing = false;
    let lastX = 0, lastY = 0;
    let cropStart = null;
    let pendingCrop = null;
    let cropRectEl = null;

    function canvasCoordsFromEvent(e) {
      const rect = displayCanvas.getBoundingClientRect();
      const scaleX = displayCanvas.width / rect.width;
      const scaleY = displayCanvas.height / rect.height;
      return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
    }

    function updateCropRectVisual(x1, y1, x2, y2) {
      if (!cropRectEl) {
        cropRectEl = document.createElement('div');
        Object.assign(cropRectEl.style, {
          position: 'absolute', border: '2px dashed #4ade80',
          background: 'rgba(74,222,128,.15)', pointerEvents: 'none',
        });
        canvasScrollWrap.appendChild(cropRectEl);
      }
      const rect = displayCanvas.getBoundingClientRect();
      const wrapRect = canvasScrollWrap.getBoundingClientRect();
      const scaleX = rect.width / displayCanvas.width;
      const scaleY = rect.height / displayCanvas.height;
      const left = Math.min(x1, x2) * scaleX + (rect.left - wrapRect.left) + canvasScrollWrap.scrollLeft;
      const top = Math.min(y1, y2) * scaleY + (rect.top - wrapRect.top) + canvasScrollWrap.scrollTop;
      Object.assign(cropRectEl.style, {
        left: left + 'px', top: top + 'px',
        width: Math.abs(x2 - x1) * scaleX + 'px', height: Math.abs(y2 - y1) * scaleY + 'px',
        display: 'block',
      });
    }

    function removeCropRectVisual() {
      if (cropRectEl) {
        cropRectEl.remove();
        cropRectEl = null;
      }
    }

    function setMode(newMode) {
      mode = newMode;
      removeCropRectVisual();
      pendingCrop = null;
      displayCanvas.style.cursor = mode ? 'crosshair' : 'default';
    }

    displayCanvas.addEventListener('mousedown', (e) => {
      if (mode === 'draw') {
        drawing = true;
        const { x, y } = canvasCoordsFromEvent(e);
        lastX = x;
        lastY = y;
      } else if (mode === 'crop') {
        cropStart = canvasCoordsFromEvent(e);
      }
    });

    displayCanvas.addEventListener('mousemove', (e) => {
      if (mode === 'draw' && drawing) {
        const { x, y } = canvasCoordsFromEvent(e);
        const ctx = workCanvas.getContext('2d');
        ctx.strokeStyle = brushColor;
        ctx.lineWidth = brushSize;
        ctx.lineCap = 'round';
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(x, y);
        ctx.stroke();
        lastX = x;
        lastY = y;
        redraw();
      } else if (mode === 'crop' && cropStart) {
        const { x, y } = canvasCoordsFromEvent(e);
        updateCropRectVisual(cropStart.x, cropStart.y, x, y);
      }
    });

    function endInteraction(e) {
      if (mode === 'draw' && drawing) {
        drawing = false;
        pushHistory();
      } else if (mode === 'crop' && cropStart) {
        const { x, y } = canvasCoordsFromEvent(e);
        const x1 = Math.max(0, Math.min(cropStart.x, x));
        const y1 = Math.max(0, Math.min(cropStart.y, y));
        const x2 = Math.min(workCanvas.width, Math.max(cropStart.x, x));
        const y2 = Math.min(workCanvas.height, Math.max(cropStart.y, y));
        cropStart = null;
        const w = x2 - x1;
        const h = y2 - y1;
        if (w > 4 && h > 4) pendingCrop = { x: x1, y: y1, w, h };
      }
    }
    displayCanvas.addEventListener('mouseup', endInteraction);
    displayCanvas.addEventListener('mouseleave', () => {
      if (drawing) {
        drawing = false;
        pushHistory();
      }
    });

    function applyCrop() {
      if (!pendingCrop) {
        alert('Hãy kéo chọn vùng cần cắt trước khi bấm Áp dụng cắt.');
        return;
      }
      const { x, y, w, h } = pendingCrop;
      const newCanvas = document.createElement('canvas');
      newCanvas.width = w;
      newCanvas.height = h;
      newCanvas.getContext('2d').drawImage(workCanvas, x, y, w, h, 0, 0, w, h);
      workCanvas.width = w;
      workCanvas.height = h;
      const ctx = workCanvas.getContext('2d');
      ctx.clearRect(0, 0, w, h);
      ctx.drawImage(newCanvas, 0, 0);
      pendingCrop = null;
      removeCropRectVisual();
      pushHistory();
      redraw();
    }

    toolbar.appendChild(makeButton('↺ Xoay trái', () => rotate(-90)));
    toolbar.appendChild(makeButton('↻ Xoay phải', () => rotate(90)));
    toolbar.appendChild(makeButton('⇋ Lật ngang', () => flip(true)));
    toolbar.appendChild(makeButton('⇕ Lật dọc', () => flip(false)));
    toolbar.appendChild(makeButton('▢ Cắt ảnh', () => setMode(mode === 'crop' ? null : 'crop')));
    toolbar.appendChild(makeButton('✓ Áp dụng cắt', applyCrop));
    toolbar.appendChild(makeButton('✎ Vẽ', () => setMode(mode === 'draw' ? null : 'draw')));

    const colorInput = document.createElement('input');
    colorInput.type = 'color';
    colorInput.value = brushColor;
    Object.assign(colorInput.style, { width: '30px', height: '28px', border: 'none', cursor: 'pointer' });
    colorInput.addEventListener('input', () => (brushColor = colorInput.value));
    toolbar.appendChild(colorInput);

    const sizeInput = document.createElement('input');
    sizeInput.type = 'range';
    sizeInput.min = '1';
    sizeInput.max = '30';
    sizeInput.value = String(brushSize);
    Object.assign(sizeInput.style, { width: '80px' });
    sizeInput.addEventListener('input', () => (brushSize = parseInt(sizeInput.value, 10)));
    toolbar.appendChild(sizeInput);

    toolbar.appendChild(makeButton('↶ Hoàn tác', undo));
    toolbar.appendChild(makeButton('⟲ Đặt lại', reset));

    const formatSelect = document.createElement('select');
    ['png', 'jpg', 'webp'].forEach((ext) => {
      const opt = document.createElement('option');
      opt.value = ext;
      opt.textContent = ext.toUpperCase();
      formatSelect.appendChild(opt);
    });
    Object.assign(formatSelect.style, {
      padding: '6px 8px', borderRadius: '5px', border: '1px solid #444',
      background: '#333', color: '#eee', fontSize: '13px',
    });

    const saveBtn = document.createElement('button');
    saveBtn.textContent = '💾 Lưu ảnh đã sửa';
    Object.assign(saveBtn.style, {
      padding: '8px 16px', borderRadius: '5px', border: 'none',
      background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600',
    });
    saveBtn.addEventListener('click', async () => {
      const ext = formatSelect.value;
      const mime = ext === 'png' ? 'image/png' : ext === 'jpg' ? 'image/jpeg' : 'image/webp';
      const filename = 'edited.' + ext;
      const handle = await pickSaveHandle(filename, mime);
      if (handle === 'cancelled') return;
      workCanvas.toBlob(
        (blob) => {
          if (!blob) {
            alert('Không tạo được ảnh để lưu.');
            return;
          }
          writeBlob(blob, filename, handle);
        },
        mime,
        ext === 'png' ? undefined : getQuality(ext)
      );
    });

    const closeBtn = makeButton('Đóng', () => overlay.remove());

    const leftFooter = document.createElement('div');
    Object.assign(leftFooter.style, { display: 'flex', gap: '8px', alignItems: 'center' });
    leftFooter.appendChild(formatSelect);
    leftFooter.appendChild(saveBtn);

    footer.appendChild(leftFooter);
    footer.appendChild(closeBtn);

    canvasScrollWrap.appendChild(displayCanvas);
    panel.appendChild(header);
    panel.appendChild(toolbar);
    panel.appendChild(canvasScrollWrap);
    panel.appendChild(footer);
    overlay.appendChild(panel);

    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) overlay.remove();
    });
    document.addEventListener('keydown', function escClose(e) {
      if (e.key === 'Escape') {
        overlay.remove();
        document.removeEventListener('keydown', escClose);
      }
    });

    document.body.appendChild(overlay);
    redraw();
  }

  // ================== MENU CUSTOM ==================
  function buildMenuGeneric(x, y, onPick, topGroups, bottomItems) {
    removeMenu();
    menuEl = document.createElement('div');
    Object.assign(menuEl.style, {
      position: 'fixed',
      top: y + 'px',
      left: x + 'px',
      zIndex: 2147483647,
      background: '#2b2b2b',
      color: '#fff',
      font: '13px/1.4 system-ui, sans-serif',
      borderRadius: '6px',
      boxShadow: '0 4px 14px rgba(0,0,0,.4)',
      padding: '4px 0',
      minWidth: '160px',
      userSelect: 'none',
    });

    function addItem(label, onClick) {
      const item = document.createElement('div');
      item.textContent = label;
      Object.assign(item.style, { padding: '6px 14px', cursor: 'pointer' });
      item.addEventListener('mouseenter', () => (item.style.background = '#444'));
      item.addEventListener('mouseleave', () => (item.style.background = 'transparent'));
      item.addEventListener('click', async (e) => {
        e.stopPropagation();
        removeMenu();
        await onClick();
      });
      menuEl.appendChild(item);
    }

    function addDivider() {
      const divider = document.createElement('div');
      Object.assign(divider.style, { borderTop: '1px solid #555', margin: '4px 0' });
      menuEl.appendChild(divider);
    }

    // topGroups: mảng các nhóm, mỗi nhóm là 1 mảng item -> mỗi nhóm có đường kẻ riêng phía dưới
    (topGroups || []).forEach((group) => {
      (group || []).forEach((it) => addItem(it.label, it.onClick));
      if (group && group.length) addDivider();
    });

    FORMATS.forEach((fmt) => {
      addItem(fmt.label, () => onPick(fmt));
    });

    if (bottomItems && bottomItems.length) {
      addDivider();
      bottomItems.forEach((it) => addItem(it.label, it.onClick));
    }

    document.body.appendChild(menuEl);
    const rect = menuEl.getBoundingClientRect();
    if (rect.right > window.innerWidth) menuEl.style.left = window.innerWidth - rect.width - 8 + 'px';
    if (rect.bottom > window.innerHeight) menuEl.style.top = window.innerHeight - rect.height - 8 + 'px';
    setTimeout(() => {
      document.addEventListener('click', removeMenu, true);
      document.addEventListener('scroll', removeMenu, true);
      document.addEventListener('keydown', onKeyDown, true);
    }, 0);
  }

  function buildMenu(x, y, img) {
    const topGroups = [
      [{ label: 'Mở hình ảnh trong tab mới', onClick: () => openImageInNewTab(img) }],
      [
        { label: 'Copy ảnh', onClick: () => copyImageToClipboard(img) },
        { label: 'Copy link ảnh', onClick: () => copyImageLink(img) },
      ],
    ];
    const bottomItems = [
      { label: 'Chỉnh sửa ảnh', onClick: () => openImageEditor(img, false) },
      { label: 'Tìm kiếm ảnh', onClick: () => searchImageOnWeb(img) },
    ];
    buildMenuGeneric(
      x,
      y,
      async (fmt) => {
        const filename = suggestedFileName(img, fmt.ext);
        const handle = await pickSaveHandle(filename, fmt.mime);
        if (handle === 'cancelled') return;
        saveImageAs(img, fmt, handle);
      },
      topGroups,
      bottomItems
    );
  }

  function buildMenuForCanvas(x, y, canvas) {
    // Canvas (vd: WebGL flipbook) không có URL ảnh gốc -> không có "Mở tab mới" / "Copy link ảnh".
    // Nhưng vẫn tìm kiếm được nhờ copy pixel trực tiếp vào clipboard.
    const topGroups = [[{ label: 'Copy ảnh', onClick: () => copyCanvasToClipboard(canvas) }]];
    const bottomItems = [
      { label: 'Chỉnh sửa ảnh', onClick: () => openImageEditor(canvas, true) },
      { label: 'Tìm kiếm ảnh', onClick: () => searchCanvasOnWeb(canvas) },
    ];
    buildMenuGeneric(
      x,
      y,
      async (fmt) => {
        const filename = 'image.' + fmt.ext;
        const handle = await pickSaveHandle(filename, fmt.mime);
        if (handle === 'cancelled') return;
        saveImageAsCanvas(canvas, fmt, handle);
      },
      topGroups,
      bottomItems
    );
  }

  // ================== PHÁT HIỆN TARGET LÀ ẢNH ==================
  function findImageLikeTarget(el) {
    if (!el) return null;
    if (el.tagName === 'IMG' && el.src) return { type: 'img', el };
    if (el.tagName === 'CANVAS') return { type: 'canvas', el };
    if (el.tagName === 'image' || el.tagName === 'IMAGE') return { type: 'svg', el };
    return null;
  }

  window.addEventListener(
    'contextmenu',
    (e) => {
      let found = findImageLikeTarget(e.target);

      if (!found && document.elementsFromPoint) {
        const stack = document.elementsFromPoint(e.clientX, e.clientY);
        for (const el of stack) {
          found = findImageLikeTarget(el);
          if (found) break;
        }
      }

      if (!found) return; // không phải ảnh -> KHÔNG động vào gì cả, để Edge hiện menu gốc bình thường

      // Là ảnh -> chặn tuyệt đối menu gốc của Edge, kể cả khi có handler khác của trang
      // (hoặc của Edge) cũng đang lắng nghe sự kiện này.
      e.preventDefault();
      e.stopImmediatePropagation();

      if (found.type === 'canvas') {
        buildMenuForCanvas(e.clientX, e.clientY, found.el);
      } else if (found.type === 'img') {
        buildMenu(e.clientX, e.clientY, found.el);
      } else if (found.type === 'svg') {
        const href = found.el.getAttribute('href') || found.el.getAttribute('xlink:href');
        if (href) {
          const fakeImg = new Image();
          fakeImg.src = href;
          buildMenu(e.clientX, e.clientY, fakeImg);
        }
      }
    },
    true
  );
})();
fen ơi, script này ngon nha. nhưng mình có thói quen right click để open new tab thì cài script này vào là bị mất chức năng đó. fen update thêm đc không
 

Thống kê chủ đề

Ngày tạo
Fioren,
Người trả lời cuối
LangBat,
Trả lời
1.081
Lượt xem
174.492
Quay lại
Lên đầu trang