图片下载器

强大的图片提取和批量下载工具,适用于绝大多数网站。轻松抓取右键限制、无法直接保存的图片,如背景图、Canvas绘制图、漫画(腾讯/B站)、图库素材(千库/包图)、文库文档图片(道客/豆丁)等。功能:ZIP打包下载、自动查找大图、图片筛选、自定义规则。(推荐 Chrome/Firefox + Tampermonkey)

目前为 2025-04-14 提交的版本。查看 最新版本

// ==UserScript==
// @name         图片下载器

// @namespace    http://tampermonkey.net/
// @version      1.00
// @description  强大的图片提取和批量下载工具,适用于绝大多数网站。轻松抓取右键限制、无法直接保存的图片,如背景图、Canvas绘制图、漫画(腾讯/B站)、图库素材(千库/包图)、文库文档图片(道客/豆丁)等。功能:ZIP打包下载、自动查找大图、图片筛选、自定义规则。(推荐 Chrome/Firefox + Tampermonkey)

// @author       shenfangda
// @match        *://*/*
// @include      *
// @connect      *
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @require      https://unpkg.com/[email protected]/dist/hotkeys.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @run-at       document-end
// @homepageURL  https://github.com/taoyuancun123/modifyText/blob/master/modifyText.js
// @supportURL   https://greasyfork.org/zh-CN/scripts/419894/feedback
// @license      GPLv3
// ==/UserScript==

(function () {
    'use strict';

    // --- Localization ---
    const lang = navigator.language || navigator.userLanguage;
    let langSet;
    const localization = {
        zh: {
            selectAll: "全选",
            downloadBtn: "下载选中",
            downloadMenuText: "打开图片下载器 (Alt+W)",
            zipDownloadBtn: "ZIP下载选中",
            selectAlert: "请至少选中一张图片。",
            fetchTip: "准备抓取 Canvas 图片...",
            fetchCount1: `抓取 Canvas 图片第 `,
            fetchCount2: ' 张',
            fetchingCanvas: "正在抓取 Canvas 图片...",
            fetchDoneTip1: "已选(0/",
            fetchDoneTip1Type2: "已选(",
            fetchDoneTip2: ")张图片",
            totalFound: "共找到 ",
            images: " 张图片",
            regRulePlace: "输入待替换正则",
            regReplacePlace: "输入替换它的字符串或函数",
            zipOptionDesc: "勾选使用zip下载后,会请求跨域权限,否则zip下载基本下载不到图片。", // This description seems obsolete as GM_xmlhttpRequest is always used now. Keep or remove? Let's keep for now.
            zipCheckText: "使用 Zip 下载", // This checkbox seems removed in the original code, relying on button choice.
            downloadUrlFile: "下载图片地址列表",
            moreSetting: "更多设置",
            autoBigImgModule: "自动大图规则",
            defaultSettingRule: "默认规则",
            exportCustomRule: "导出自定义规则",
            importCustomRule: "导入自定义规则",
            fold: "收起",
            inputFilenameTip: "输入下载文件名前缀",
            extraGrab: "强力抓取(实验性)",
            extraGrabTooltip: "尝试拦截所有动态加载的图片,可能影响页面性能,需刷新页面生效。",
            shortcutInfo: "快捷键",
            filterWidth: "宽度:",
            filterHeight: "高度:",
            preparingZip: "正在准备 ZIP 文件...",
            zipReady: "ZIP 文件准备就绪!",
            downloadingImages: "正在下载图片...",
            downloadComplete: "下载完成!",
        },
        en: {
            selectAll: "Select All",
            downloadBtn: "Download Selected",
            downloadMenuText: "Open Image Downloader (Alt+W)",
            zipDownloadBtn: "ZIP Download Selected",
            selectAlert: "Please select at least one image.",
            fetchTip: "Preparing to fetch Canvas images...",
            fetchCount1: `Fetching canvas image #`,
            fetchCount2: '',
            fetchingCanvas: "Fetching Canvas images...",
            fetchDoneTip1: "Selected (0/",
            fetchDoneTip1Type2: "Selected (",
            fetchDoneTip2: ") images",
            totalFound: "Found ",
            images: " images",
            regRulePlace: "Enter regex to replace",
            regReplacePlace: "Enter replacement string or function",
            zipOptionDesc: "When zip option checked, will request CORS right, otherwise zipDownload may not get all pics.",
            zipCheckText: "Use Zip Download",
            downloadUrlFile: "Download Image URL List",
            moreSetting: "More Settings",
            autoBigImgModule: "Auto Big Image Rules",
            defaultSettingRule: "Default Rules",
            exportCustomRule: "Export Custom Rules",
            importCustomRule: "Import Custom Rules",
            fold: "Fold",
            inputFilenameTip: "Enter download filename prefix",
            extraGrab: "Extra Grab (Experimental)",
            extraGrabTooltip: "Try to intercept all dynamically loaded images. May impact page performance. Requires page refresh to take effect.",
            shortcutInfo: "Shortcut",
            filterWidth: "Width:",
            filterHeight: "Height:",
            preparingZip: "Preparing ZIP file...",
            zipReady: "ZIP file ready!",
            downloadingImages: "Downloading images...",
            downloadComplete: "Download complete!",
        }
    };

    if (lang.toLowerCase().startsWith("zh-")) {
        langSet = localization.zh;
    } else {
        // Default to English for other languages for now
        langSet = localization.en;
    }

    // --- Global Variables & State ---
    let currentImgUrls = [];          // URLs discovered in the current run
    let imgSelectedIndices = [];      // Indices of selected images (relative to filteredImgUrls)
    let filteredImgUrls = [];         // URLs after filtering and auto-big-image processing
    let zipBase64Sources = {};        // Store Base64 data for ZIP, keyed by original URL
    let isFetchingBase64 = false;     // Flag to prevent concurrent Base64 fetching
    let downloadFileNameBase = '';    // Base name for downloads
    let shortCutString = "alt+w";     // Default shortcut
    const originalSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
    let interceptedSrcs = [];         // Store srcs captured by 'Extra Grab'

    // --- Auto Big Image Module ---
    const autoBigImage = {
        // ... (Keep the autoBigImage object exactly as it was in the original provided script) ...
        // Including: bigImageArray, defaultRules, defaultRulesChecked, userRules, userRulesChecked,
        // replace(), getBigImageArray(), showDefaultRules(), showRules(), onclickShowDefaultBtn(),
        // oncheckChange(), oncheckChangeCustom(), setRulesChecked(), getCustomRules(), setCustomRules(),
        // exportCustomRules()

        // --- Start of autoBigImage Object definition ---
        bigImageArray: [],
        defaultRules:[
            {originReg:/(?<=(.+sinaimg\.(?:cn|com)\/))([\w\.]+)(?=(\/.+))/i,replacement:"large",tip:"for weib.com"},
            {originReg:/(?<=(.+alicdn\.(?:cn|com)\/.+\.(jpg|jpeg|gif|png|bmp|webp)))_.+/i,replacement:"",tip:"for alibaba web"},
            {originReg:/(.+alicdn\.(?:cn|com)\/.+)(\.\d+x\d+)(\.(jpg|jpeg|gif|png|bmp|webp)).*/i,replacement:(match,p1,p2,p3)=>p1+p3,tip:"for 1688"},
            {originReg:/(?<=(.+360buyimg\.(?:cn|com)\/))(\w+\/)(?=(.+\.(jpg|jpeg|gif|png|bmp|webp)))/i,replacement:"n0/",tip:"for jd"},
            {originReg:/(?<=(.+hdslb\.(?:cn|com)\/.+\.(jpg|jpeg|gif|png|bmp|webp)))@.+/i,replacement:"",tip:"for bilibili"},
            {originReg:/th(\.wallhaven\.cc\/)(?!full).+\/(\w{2}\/)([\w\.]+)(\.jpg)/i,replacement:(match,p1,p2,p3)=>"w"+p1+"full/"+p2+"wallhaven-"+p3+".jpg",tip:"for wallhaven"},
            {originReg:/th(\.wallhaven\.cc\/)(?!full).+\/(\w{2}\/)([\w\.]+)(\.png)/i,replacement:(match,p1,p2,p3)=>"w"+p1+"full/"+p2+"wallhaven-"+p3+".png",tip:"for wallhaven png"}, // Added PNG variant
            {originReg:/(.*\.twimg\.\w+\/.+\?format=)(\w+)(\&name=*)(.*)/i,replacement:(match,p1,p2,p3,p4)=>p1+p2+p3+"orig",tip:"for twitter new format"}, // Updated twitter rule
             {originReg:/(.*\.twimg\.\w+\/.+\&name=*)(.*)/i,replacement:(match,p1,p2,p3)=>p1+"orig",tip:"for twitter old format"},
            {originReg:/(shonenjump\.com\/.*\/)poster_thumb(\/.*)/,replacement:'$1poster$2',tip:"for www.shonenjump.com"},
            {originReg:/(qzone\.qq\.com.*!!\/.*)$/,replacement:'$1/0',tip:"for Qzone"},
            {originReg:/(.*wordpress\.com.*)(\?w=\d+)$/,replacement:'$1',tip:"for wordpress"},
            {originReg:/(img\.ithome\.com\/newsuploadfiles.*)_.*\.(jpg|png|gif|webp)/i,replacement:'$1.$2',tip:"for ithome.com"}, // Example new rule
        ],
        defaultRulesChecked: [],
        userRules: [],
        userRulesChecked: [],
        replace(originImgUrls) {
            let that = this;
            that.bigImageArray = [];
            // Ensure unique, non-empty URLs
            let tempArray = Array.from(new Set(originImgUrls)).filter(item => typeof item === 'string' && item.trim() !== '');
            that.setRulesChecked(); // Load checked status

            tempArray.forEach(urlStr => {
                if (!urlStr) return;
                let replaced = false; // Flag to track if any rule matched

                // Handle data URLs directly
                if (urlStr.startsWith("data:image/")) {
                    that.bigImageArray.push(urlStr);
                    return;
                }

                // Apply default rules
                that.defaultRules.forEach((rule, ruleIndex) => {
                    if (that.defaultRulesChecked[ruleIndex] !== "checked") return;
                    try {
                        let bigImage = urlStr.replace(rule.originReg, rule.replacement);
                        if (bigImage !== urlStr) {
                            that.bigImageArray.push(bigImage); // Add the potentially larger image
                            replaced = true;
                            // console.log(`Rule ${rule.tip || ruleIndex} applied: ${urlStr} -> ${bigImage}`);
                        }
                    } catch (e) {
                        console.error("Error applying default rule:", rule, e);
                    }
                });

                // Apply user rules
                that.userRules.forEach((rule, ruleIndex) => {
                     if (that.userRulesChecked[ruleIndex] !== "checked") return;
                     try {
                         // Ensure RegExp is valid if loaded from string
                         let regExp = rule.originReg;
                         if (typeof regExp === 'string') {
                              try {
                                 const match = regExp.match(/^\/(.+)\/([gimyus]*)$/);
                                 if (match) {
                                     regExp = new RegExp(match[1], match[2]);
                                 } else {
                                     // Assume it's just the pattern, add 'i' flag by default if none provided
                                     regExp = new RegExp(regExp, 'i');
                                 }
                              } catch (reError) {
                                 console.error("Invalid RegExp string in user rule:", rule.originReg, reError);
                                 return; // Skip this rule
                             }
                         }

                         let replacementFunc = rule.replacement;
                         if (typeof replacementFunc === 'string' && replacementFunc.startsWith('(') && replacementFunc.includes('=>')) {
                              try {
                                 replacementFunc = eval(replacementFunc); // Evaluate string to function if it looks like an arrow function
                              } catch (evalError) {
                                  console.error("Invalid replacement function string in user rule:", rule.replacement, evalError);
                                  replacementFunc = rule.replacement; // Keep as string if eval fails
                              }
                         }


                         let bigImage = urlStr.replace(regExp, replacementFunc);
                         if (bigImage !== urlStr) {
                             that.bigImageArray.push(bigImage);
                             replaced = true;
                              // console.log(`User rule ${ruleIndex} applied: ${urlStr} -> ${bigImage}`);
                         }
                     } catch (e) {
                         console.error("Error applying user rule:", rule, e);
                     }
                 });

                // If no rule resulted in a replacement, add the original URL
                if (!replaced) {
                    that.bigImageArray.push(urlStr);
                }
            });

            // Ensure original URLs are also included if they weren't replaced
            // This logic might be complex depending on whether we want *only* big or *both*
            // Current logic adds *only* the big one if replaced, otherwise the original.
            // To include both original and big: push urlStr *before* the loops, then push bigImage inside if different.
            // Let's refine: Add original first, then add big if different.

            that.bigImageArray = []; // Reset
            tempArray.forEach(urlStr => {
                 if (!urlStr || urlStr.startsWith("data:image/")) {
                     if (urlStr) that.bigImageArray.push(urlStr);
                     return;
                 }

                 that.bigImageArray.push(urlStr); // Add original first

                 let foundBig = false;

                 // Apply default rules
                 that.defaultRules.forEach((rule, ruleIndex) => {
                    if (that.defaultRulesChecked[ruleIndex] !== "checked" || foundBig) return;
                     try {
                         let bigImage = urlStr.replace(rule.originReg, rule.replacement);
                         if (bigImage !== urlStr) {
                             that.bigImageArray.push(bigImage);
                             foundBig = true;
                         }
                     } catch (e) { console.error("Error applying default rule:", rule, e); }
                 });

                // Apply user rules
                 that.userRules.forEach((rule, ruleIndex) => {
                      if (that.userRulesChecked[ruleIndex] !== "checked" || foundBig) return;
                     try {
                          let regExp = rule.originReg;
                           if (typeof regExp === 'string') {
                                try {
                                    const match = regExp.match(/^\/(.+)\/([gimyus]*)$/);
                                    regExp = match ? new RegExp(match[1], match[2]) : new RegExp(regExp, 'i');
                                } catch (reError) { console.error("Invalid RegExp string in user rule:", rule.originReg, reError); return; }
                           }

                           let replacementFunc = rule.replacement;
                           if (typeof replacementFunc === 'string' && replacementFunc.startsWith('(') && replacementFunc.includes('=>')) {
                               try { replacementFunc = eval(replacementFunc); } catch (evalError) { console.error("Invalid replacement function string:", rule.replacement, evalError); }
                           }

                         let bigImage = urlStr.replace(regExp, replacementFunc);
                         if (bigImage !== urlStr) {
                             that.bigImageArray.push(bigImage);
                             foundBig = true;
                         }
                     } catch (e) { console.error("Error applying user rule:", rule, e); }
                 });

            });

        },
        getBigImageArray(originImgUrls) {
            this.replace(originImgUrls);
            // Return unique URLs only
            return Array.from(new Set(this.bigImageArray)).filter(Boolean);
        },
        showDefaultRules() {
            let that = this;
            let defaultContainer = document.body.querySelector(".tyc-set-domain-default");
            if (!defaultContainer) return;
            defaultContainer.innerHTML = ''; // Clear previous content
            that.setRulesChecked();

            this.defaultRules.forEach((v, i) => {
                const regValue = v.originReg instanceof RegExp ? v.originReg.toString() : v.originReg;
                const repValue = typeof v.replacement === 'function' ? v.replacement.toString() : v.replacement;
                let rulesHtml = `<div class="tyc-set-replacerule">
                            <input type="checkbox" name="active" class="tyc-default-active" ${that.defaultRulesChecked[i] || ''}>
                            <input type="text" name="regrule" placeholder="${langSet.regRulePlace}" class="tyc-search-title" value="${escapeHtml(regValue)}">
                            <input type="text" name="replace" placeholder="${langSet.regReplacePlace}" class="tyc-search-url" value="${escapeHtml(repValue)}">
                            <span class="tyc-default-tip">${v.tip || ''}</span>
                    </div>
                `;
                defaultContainer.insertAdjacentHTML("beforeend", rulesHtml);
            });
        },
        showRules(containerName, rulesType, checkType, checkClassName) {
            let that = this;
            let Container = document.body.querySelector("." + containerName);
             if (!Container) return;
             Container.innerHTML = ''; // Clear previous content
            that.setRulesChecked();
            that.setCustomRules(); // Ensure user rules are loaded

            that[rulesType].forEach((v, i) => {
                const regValue = v.originReg instanceof RegExp ? v.originReg.toString() : v.originReg;
                const repValue = typeof v.replacement === 'function' ? v.replacement.toString() : v.replacement;
                let rulesHtml = `<div class="tyc-set-replacerule">
                            <input type="checkbox" name="active" class="${checkClassName}" ${that[checkType][i] || ''}>
                            <input type="text" name="regrule" placeholder="${langSet.regRulePlace}" class="tyc-search-title" value="${escapeHtml(regValue)}">
                            <input type="text" name="replace" placeholder="${langSet.regReplacePlace}" class="tyc-search-url" value="${escapeHtml(repValue)}">
                            <span class="tyc-default-tip">${v.tip || ''}</span>
                    </div>
                `;
                Container.insertAdjacentHTML("beforeend", rulesHtml);
            });
        },
         onclickShowDefaultBtn() {
            let defaultContainer = document.body.querySelector(".tyc-set-domain-default");
            if (!defaultContainer) return;
            if (defaultContainer.style.display === "none" || defaultContainer.style.display === '') {
                defaultContainer.style.display = "flex"; // Or block, depending on desired layout
            } else {
                defaultContainer.style.display = "none";
            }
        },
        oncheckChange() {
            let checks = document.body.querySelectorAll(".tyc-default-active");
            this.defaultRulesChecked = [];
            checks.forEach(v => {
                this.defaultRulesChecked.push(v.checked ? "checked" : "");
            });
            GM_setValue("defaultRulesChecked", this.defaultRulesChecked);
            // console.log("Default rules checked status saved:", this.defaultRulesChecked);
             // Optionally re-filter images immediately after changing rules
            if (document.querySelector(".tyc-image-container")) {
                initUI(); // Re-initialize UI which includes filtering
            }
        },
        oncheckChangeCustom() {
            let checks = document.body.querySelectorAll(".tyc-custom-active");
             this.userRulesChecked = [];
             checks.forEach(v => {
                this.userRulesChecked.push(v.checked ? "checked" : "");
            });
            GM_setValue("userRulesChecked", this.userRulesChecked);
            // console.log("User rules checked status saved:", this.userRulesChecked);
             // Optionally re-filter images immediately after changing rules
             if (document.querySelector(".tyc-image-container")) {
                initUI(); // Re-initialize UI which includes filtering
            }
        },
        setRulesChecked() {
            // Default rules
            const storedDefaultChecks = GM_getValue("defaultRulesChecked");
            if (storedDefaultChecks && Array.isArray(storedDefaultChecks)) {
                 this.defaultRulesChecked = storedDefaultChecks;
                 // Ensure length matches current default rules, adding "checked" for new rules
                 if (this.defaultRulesChecked.length < this.defaultRules.length) {
                     const delta = this.defaultRules.length - this.defaultRulesChecked.length;
                     for (let i = 0; i < delta; i++) {
                         this.defaultRulesChecked.push("checked");
                     }
                     GM_setValue("defaultRulesChecked", this.defaultRulesChecked); // Save updated checks
                 } else if (this.defaultRulesChecked.length > this.defaultRules.length) {
                     // If rules were removed, shorten the checks array
                     this.defaultRulesChecked = this.defaultRulesChecked.slice(0, this.defaultRules.length);
                      GM_setValue("defaultRulesChecked", this.defaultRulesChecked);
                 }
             } else {
                // Initialize if not set
                 this.defaultRulesChecked = this.defaultRules.map(() => "checked");
                 GM_setValue("defaultRulesChecked", this.defaultRulesChecked);
            }

            // User rules
            this.setCustomRules(); // Ensure user rules are loaded first
             const storedUserChecks = GM_getValue("userRulesChecked");
             if (storedUserChecks && Array.isArray(storedUserChecks)) {
                 this.userRulesChecked = storedUserChecks;
                 // Adjust length similar to default rules
                 if (this.userRulesChecked.length < this.userRules.length) {
                      const delta = this.userRules.length - this.userRulesChecked.length;
                      for (let i = 0; i < delta; i++) {
                          this.userRulesChecked.push("checked");
                      }
                      GM_setValue("userRulesChecked", this.userRulesChecked);
                  } else if (this.userRulesChecked.length > this.userRules.length) {
                      this.userRulesChecked = this.userRulesChecked.slice(0, this.userRules.length);
                      GM_setValue("userRulesChecked", this.userRulesChecked);
                  }
              } else {
                 // Initialize if not set
                  this.userRulesChecked = this.userRules.map(() => "checked");
                  GM_setValue("userRulesChecked", this.userRulesChecked);
             }
         },
         getCustomRules(event) {
            const fileInput = event.target; // Should be the hidden file input
            if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
                console.log("No file selected for import.");
                return;
            }
            const file = fileInput.files[0];
            const fileReader = new FileReader();

            fileReader.onload = (e) => {
                 const result = e.target.result;
                 try {
                     // Use a safer approach than eval if possible, but eval is common for this.
                     // Consider JSON if rules can be structured that way. Assuming eval for now based on original code.
                     let importedRules = eval(result); // Be cautious with eval!
                     if (!Array.isArray(importedRules)) {
                         throw new Error("Imported data is not an array.");
                     }
                     // Basic validation of rule structure
                     importedRules = importedRules.filter(rule => rule && typeof rule.originReg !== 'undefined' && typeof rule.replacement !== 'undefined');

                     this.userRules = importedRules;

                     // Reset checks and save
                     GM_deleteValue("userRulesChecked");
                     this.setRulesChecked(); // This will re-initialize userRulesChecked based on the new userRules length
                     GM_setValue("userRules", JSON.stringify(this.userRules)); // Store as JSON string

                     console.log("Custom rules imported successfully:", this.userRules);
                      alert("Custom rules imported successfully!");

                     // Refresh the display
                     const customContainer = document.body.querySelector(".tyc-set-domain-custom");
                      if (customContainer) {
                         customContainer.innerHTML = ""; // Clear existing display
                         this.showRules("tyc-set-domain-custom", "userRules", "userRulesChecked", "tyc-custom-active");
                     }
                      // Re-filter images with new rules
                      if (document.querySelector(".tyc-image-container")) {
                         initUI();
                     }

                 } catch (error) {
                     console.error("Error importing custom rules:", error);
                     alert(`Error importing custom rules: ${error.message}\nPlease ensure the file contains a valid JavaScript array of rule objects.`);
                 } finally {
                     // Reset file input value to allow importing the same file again
                     fileInput.value = '';
                 }
             };

            fileReader.onerror = (e) => {
                 console.error("Error reading file:", e);
                 alert("Error reading the selected file.");
                 fileInput.value = ''; // Reset input
             };

             fileReader.readAsText(file); // Default encoding (UTF-8 usually)
         },
         setCustomRules() {
            const storedRules = GM_getValue("userRules");
            if (storedRules) {
                 try {
                     this.userRules = JSON.parse(storedRules); // Parse JSON string
                     if (!Array.isArray(this.userRules)) {
                         console.warn("Stored user rules are not an array, resetting.");
                         this.userRules = [];
                         GM_setValue("userRules", "[]"); // Store empty array as JSON
                     }
                 } catch (error) {
                     console.error("Error parsing stored user rules:", error);
                     this.userRules = [];
                     GM_setValue("userRules", "[]"); // Reset on error
                 }
             } else {
                 this.userRules = []; // Initialize if not found
             }
         },
        exportCustomRules() {
             this.setCustomRules(); // Ensure current rules are loaded
             if (!this.userRules || this.userRules.length === 0) {
                 alert("No custom rules to export.");
                 return;
             }
             try {
                 // Convert RegExp and Functions to strings for reliable serialization
                 const exportableRules = this.userRules.map(rule => ({
                      ...rule,
                      originReg: rule.originReg instanceof RegExp ? rule.originReg.toString() : rule.originReg,
                      replacement: typeof rule.replacement === 'function' ? rule.replacement.toString() : rule.replacement,
                 }));

                 // Use JSON.stringify for a structured format, but eval will be needed on import.
                 // Or create a more JS-like string representation. Let's stick to JSON string for easier parsing.
                 const rulesString = JSON.stringify(exportableRules, null, 2); // Pretty print JSON

                 // Alternative: Create a JS array string (might be closer to original intent if using eval)
                 // const rulesString = `[${exportableRules.map(rule =>
                 //     `\n  { originReg: ${rule.originReg.includes('/') ? rule.originReg : `/${rule.originReg}/i`}, replacement: ${JSON.stringify(rule.replacement)}, tip: ${JSON.stringify(rule.tip || '')} }`
                 // ).join(',')} \n]`;


                 const blob = new Blob([rulesString], { type: "text/plain;charset=utf-8" });
                 saveAs(blob, "image_downloader_custom_rules.txt"); // Or .json if using JSON stringify
                 console.log("Custom rules exported.");
             } catch (error) {
                 console.error("Error exporting custom rules:", error);
                 alert(`Error exporting rules: ${error.message}`);
             }
         }

        // --- End of autoBigImage Object definition ---
    };


    // --- Utility Functions ---
    function escapeHtml(unsafe) {
        if (typeof unsafe !== 'string') return unsafe;
        return unsafe
             .replace(/&/g, "&amp;")
             .replace(/</g, "&lt;")
             .replace(/>/g, "&gt;")
             .replace(/"/g, "&quot;")
             .replace(/'/g, "&#039;");
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // --- Core Logic ---

    /**
     * Intercepts image src assignments if 'Extra Grab' is enabled.
     */
    function enableExtraGrab() {
        if (!originalSrcDescriptor) return; // Safety check
         try {
            Object.defineProperty(HTMLImageElement.prototype, 'src', {
                configurable: true, // Allow redefining later
                get: function() {
                    return originalSrcDescriptor.get.call(this);
                },
                set: function(value) {
                    if (value && typeof value === 'string' && !interceptedSrcs.includes(value)) {
                        interceptedSrcs.push(value);
                        // console.log('Extra Grab intercepted:', value);
                    }
                    originalSrcDescriptor.set.call(this, value);
                }
            });
            console.log("Image Downloader: Extra Grab enabled.");
         } catch (e) {
             console.error("Image Downloader: Failed to enable Extra Grab.", e);
         }
    }

    /**
      * Restores the original image src descriptor.
      */
    function disableExtraGrab() {
        if (!originalSrcDescriptor) return;
        try {
            Object.defineProperty(HTMLImageElement.prototype, 'src', originalSrcDescriptor);
            console.log("Image Downloader: Extra Grab disabled.");
        } catch (e) {
            console.error("Image Downloader: Failed to disable Extra Grab.", e);
        }
    }

    /**
     * Initializes variables at the start of the wrapper function.
     */
    function setupVariables() {
        currentImgUrls = [];
        imgSelectedIndices = [];
        filteredImgUrls = [];
        zipBase64Sources = {};
        isFetchingBase64 = false;

        // Generate default filename
        try {
            const domainParts = document.domain.split('.');
            const mainDomain = domainParts.length > 1 ? domainParts[domainParts.length - 2] : document.domain;
            const timeStamp = new Date().getTime().toString();
            downloadFileNameBase = `${mainDomain}_${timeStamp.slice(-6)}`; // Shorter timestamp
        } catch (e) {
            console.error("Error generating filename:", e);
            downloadFileNameBase = `images_${new Date().getTime().toString().slice(-6)}`;
        }

        // Load shortcut from storage
        shortCutString = GM_getValue("shortCutString") || "alt+w";
    }

    /**
     * Finds images from various sources (img tags, srcset, background-image, canvas).
     */
    function discoverImages() {
        console.log("Image Downloader: Discovering images...");
        const discoveredUrls = new Set(); // Use a Set for automatic deduplication initially

        // 1. From <img> tags (src and srcset)
        try {
            const imgElements = document.getElementsByTagName("img");
            for (const img of imgElements) {
                if (img.src && !img.src.startsWith('javascript:')) {
                    discoveredUrls.add(img.src);
                }
                if (img.srcset) {
                    const sources = img.srcset.split(',').map(s => s.trim().split(/\s+/)[0]);
                    sources.forEach(src => {
                        if (src && !src.startsWith('javascript:')) discoveredUrls.add(src)
                    });
                    // Basic high-res check (could be improved by parsing 'w' descriptors)
                    if (sources.length > 0) discoveredUrls.add(sources[sources.length - 1]);
                }
            }
        } catch (e) {
            console.error("Error discovering images from <img> tags:", e);
        }

         // 2. From intercepted sources ('Extra Grab')
         interceptedSrcs.forEach(src => discoveredUrls.add(src));

        // 3. From CSS background-image
        try {
            const styleSheets = Array.from(document.styleSheets);
             styleSheets.forEach(sheet => {
                try {
                    const rules = Array.from(sheet.cssRules || []);
                    rules.forEach(rule => {
                        if (rule.style && rule.style.backgroundImage) {
                            const bgUrlMatch = rule.style.backgroundImage.match(/url\(['"]?(.+?)['"]?\)/);
                             if (bgUrlMatch && bgUrlMatch[1] && !bgUrlMatch[1].startsWith('data:') && !bgUrlMatch[1].startsWith('javascript:')) {
                                 // Resolve relative URLs
                                 discoveredUrls.add(new URL(bgUrlMatch[1], document.baseURI).href);
                             }
                         }
                     });
                 } catch (sheetError) {
                     // Ignore CORS errors for external stylesheets
                     if (!sheetError.message.includes('Cannot access')) {
                         // console.warn("Error processing stylesheet:", sheetError);
                     }
                 }
             });

             // Also check inline styles (less efficient but necessary)
             const allElements = document.querySelectorAll('*');
             allElements.forEach(el => {
                 if (el.style && el.style.backgroundImage) {
                      const bgUrlMatch = el.style.backgroundImage.match(/url\(['"]?(.+?)['"]?\)/);
                      if (bgUrlMatch && bgUrlMatch[1] && !bgUrlMatch[1].startsWith('data:') && !bgUrlMatch[1].startsWith('javascript:')) {
                         discoveredUrls.add(new URL(bgUrlMatch[1], document.baseURI).href);
                      }
                  }
              });

         } catch (e) {
             console.error("Error discovering images from background-image:", e);
         }


        // 4. Special handling for specific sites (like the original hathitrust)
        if (window.location.href.includes("hathitrust.org")) {
            try {
                const imgs = document.querySelectorAll(".image img");
                if (imgs.length > 0) {
                    const canvas = document.createElement("canvas");
                    for (const img of imgs) {
                         try {
                            canvas.width = img.naturalWidth || img.width;
                            canvas.height = img.naturalHeight || img.height;
                             if (canvas.width > 0 && canvas.height > 0) {
                                canvas.getContext("2d").drawImage(img, 0, 0);
                                discoveredUrls.add(canvas.toDataURL("image/png"));
                             }
                         } catch(imgCanvasError) {
                            console.warn("HathiTrust: Error processing image to canvas", imgCanvasError);
                         }
                    }
                }
            } catch(hathiError) {
                console.error("HathiTrust specific handling error:", hathiError);
            }
        }

        // 5. BiliBili Manga anti-scraping bypass (if still needed/working)
        if (window.location.href.includes("manga.bilibili.com/")) {
             try {
                 let iframe = document.getElementById("tyc-insert-iframe");
                 if (!iframe) {
                     iframe = document.createElement("iframe");
                     iframe.style.display = "none";
                     iframe.id = "tyc-insert-iframe";
                     document.body.insertAdjacentElement("afterbegin", iframe);
                     iframe.contentDocument.body.insertAdjacentHTML("afterbegin", `<canvas id="tyc-insert-canvas"></canvas>`);
                 }
                 const originalCanvasProto = document.body.getElementsByTagName('canvas')[0]?.__proto__;
                 const targetCanvasProto = iframe.contentDocument.getElementById("tyc-insert-canvas")?.__proto__;
                 if (originalCanvasProto && targetCanvasProto && originalCanvasProto.toBlob !== targetCanvasProto.toBlob) {
                     console.log("Attempting BiliBili canvas bypass...");
                     originalCanvasProto.toBlob = targetCanvasProto.toBlob;
                 }
             } catch (biliError) {
                 console.error("BiliBili Manga specific handling error:", biliError);
             }
         }

        // Convert Set to Array and store
        currentImgUrls = Array.from(discoveredUrls).filter(Boolean); // Filter out any potential null/undefined values
        console.log(`Image Downloader: Discovered ${currentImgUrls.length} potential image URLs.`);

        // Asynchronously handle canvas elements
        return handleCanvasElements();
    }

    /**
     * Finds canvas elements and converts them to data URLs asynchronously.
     * Returns a Promise that resolves when all canvases are processed.
     */
     function handleCanvasElements() {
         return new Promise((resolve) => {
             const canvasElements = document.getElementsByTagName("canvas");
             if (canvasElements.length === 0) {
                 console.log("No canvas elements found.");
                 resolve(); // Resolve immediately if no canvases
                 return;
             }

             console.log(`Found ${canvasElements.length} canvas elements. Attempting to extract...`);
             updateStatusTip(langSet.fetchingCanvas);

             let processedCount = 0;
             let extractedCount = 0;
             const canvasPromises = [];

             for (let i = 0; i < canvasElements.length; i++) {
                 const canvas = canvasElements[i];
                 // Skip tiny or potentially non-image canvases
                 if (canvas.width < 16 || canvas.height < 16) {
                      processedCount++;
                      continue;
                 }

                 const promise = new Promise((canvasResolve, canvasReject) => {
                     try {
                         canvas.toBlob(
                             (blob) => {
                                 if (blob) {
                                     const reader = new FileReader();
                                     reader.onloadend = function () {
                                         const base64 = reader.result;
                                         if (base64 && typeof base64 === 'string' && base64.startsWith("data:image")) {
                                             if (!currentImgUrls.includes(base64)) { // Check before adding
                                                currentImgUrls.push(base64);
                                                extractedCount++;
                                             }
                                         }
                                         processedCount++;
                                         updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                                         canvasResolve(); // Resolve this canvas promise
                                     };
                                     reader.onerror = function (e) {
                                         console.warn("FileReader error for canvas blob:", e);
                                         processedCount++;
                                         updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                                         canvasResolve(); // Still resolve, just couldn't read
                                     };
                                     reader.readAsDataURL(blob);
                                 } else {
                                      // console.log(`Canvas ${i} toBlob returned null.`);
                                      processedCount++;
                                      updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                                      canvasResolve(); // Resolve even if blob is null
                                 }
                             },
                             'image/png' // Specify type (png is usually lossless)
                         );
                     } catch (e) {
                          // console.warn(`Error calling toBlob on canvas ${i}:`, e);
                          processedCount++;
                          updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                          canvasResolve(); // Resolve on error to not block others
                     }
                 });
                  canvasPromises.push(promise);
             }

             // Wait for all canvas processing attempts to complete
             Promise.all(canvasPromises).then(() => {
                 console.log(`Canvas processing complete. Extracted ${extractedCount} new images.`);
                 // Final deduplication after adding canvas images
                 currentImgUrls = Array.from(new Set(currentImgUrls));
                  resolve(); // Resolve the main promise
             });
         });
     }


    /**
      * Creates the downloader UI panel.
      */
    function createUI() {
        // Remove existing panel first
        const existingContainer = document.querySelector(".tyc-image-container");
        if (existingContainer) {
            existingContainer.remove();
        }

        const css = `
            .tyc-image-container {
                color: #333;
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                z-index: 2147483645; /* High z-index */
                background-color: rgba(240, 240, 240, 0.98); /* Slightly transparent background */
                border: 1px solid #ccc;
                overflow-y: scroll; /* Allow vertical scroll */
                display: flex;
                flex-direction: column;
                font-family: sans-serif;
                font-size: 13px;
                 box-sizing: border-box;
            }
             .tyc-image-container *, .tyc-image-container *::before, .tyc-image-container *::after {
                 box-sizing: inherit; /* Inherit box-sizing */
            }

            .tyc-control-section {
                width: 100%;
                padding: 8px 15px;
                background-color: #e8e8e8;
                border-bottom: 1px solid #ccc;
                z-index: 2147483646; /* Above image wrapper */
                position: sticky; /* Keep controls visible */
                top: 0;
                display: flex;
                flex-direction: column;
                gap: 8px; /* Spacing between control rows */
            }

            .tyc-control-row {
                display: flex;
                flex-wrap: wrap; /* Allow wrapping on smaller screens */
                align-items: center;
                gap: 10px; /* Spacing between controls */
            }

            .tyc-image-container button, .tyc-image-container input[type="text"], .tyc-image-container input[type="number"] {
                border: 1px solid #aaa;
                border-radius: 4px;
                padding: 5px 8px;
                height: 30px;
                font-size: 12px;
                 background-color: #fff;
            }
            .tyc-image-container input[type="text"], .tyc-image-container input[type="number"] {
                max-width: 120px;
             }

            .tyc-image-container button {
                cursor: pointer;
                background-color: #f0f0f0;
                transition: background-color 0.2s ease, color 0.2s ease;
                 white-space: nowrap;
             }

            .tyc-image-container button:hover {
                background-color: #007bff;
                color: #fff;
                border-color: #0056b3;
             }
             .tyc-image-container button:active {
                 background-color: #0056b3;
             }

             .tyc-btn-close {
                position: absolute;
                top: 8px;
                right: 15px;
                font-size: 20px;
                font-weight: bold;
                padding: 0 10px;
                height: 30px;
                line-height: 28px;
                border-radius: 50%;
                background-color: #ddd;
                color: #555;
                border: 1px solid #aaa;
            }
            .tyc-btn-close:hover {
                 background-color: #f56c6c;
                 color: white;
                 border-color: #f56c6c;
            }

             .tyc-image-wrapper {
                padding: 15px;
                display: flex;
                flex-wrap: wrap;
                justify-content: center; /* Center images horizontally */
                align-items: flex-start; /* Align tops of images */
                gap: 10px; /* Spacing between image items */
                flex-grow: 1; /* Take remaining vertical space */
            }

            .tyc-img-item-container {
                border: 2px solid #ccc; /* Default border */
                border-radius: 4px;
                overflow: hidden; /* Contain image and info */
                position: relative; /* For positioning info overlay */
                background-color: #fff;
                display: flex; /* Use flex for centering image if needed */
                flex-direction: column;
                transition: border-color 0.2s ease;
                width: 200px; /* Default width */
                height: 220px; /* Default height including potential info */
             }
             .tyc-img-item-container.selected {
                 border-color: #007bff; /* Highlight selected */
             }

             .tyc-image-preview {
                 display: block; /* Remove extra space below image */
                 width: 100%;
                 height: 180px; /* Fixed height for preview area */
                 object-fit: contain; /* Scale image while preserving aspect ratio */
                 cursor: pointer;
                 background-color: #f0f0f0; /* Placeholder background */
             }
             .tyc-image-preview:hover {
                 opacity: 0.85;
            }
              /* Hide broken image icons */
             .tyc-image-preview[src=""], .tyc-image-preview:not([src]) {
                visibility: hidden; /* Or use background image */
             }
             .tyc-image-preview::before { /* Placeholder text */
                 content: 'Loading...';
                 display: flex;
                 align-items: center;
                 justify-content: center;
                 height: 100%;
                 color: #aaa;
                 font-size: 12px;
                 visibility: visible; /* Ensure placeholder is visible */
              }
              .tyc-image-preview[data-loaded="true"]::before {
                 display: none; /* Hide placeholder when loaded */
             }


             .tyc-image-info-container {
                height: 40px; /* Space for info and buttons */
                background-color: rgba(230, 230, 230, 0.9);
                padding: 5px;
                display: flex;
                justify-content: space-around; /* Distribute items */
                align-items: center;
                font-size: 11px;
                color: #555;
                border-top: 1px solid #ddd;
            }

             .tyc-image-dimensions {
                white-space: nowrap;
             }

            .tyc-img-actions button {
                 background: none;
                 border: none;
                 padding: 2px;
                 cursor: pointer;
                 color: #555;
                 height: auto;
             }
             .tyc-img-actions button:hover {
                 color: #007bff;
                 background: none;
            }
             .tyc-img-actions svg {
                 width: 18px;
                 height: 18px;
                 vertical-align: middle;
             }

            .tyc-input-checkbox { margin-right: 3px; vertical-align: middle; }
            .tyc-label { vertical-align: middle; margin-right: 5px; }

             .tyc-filter-group { display: inline-flex; align-items: center; gap: 5px; }
            .tyc-filter-group input[type="number"] { width: 60px; }

            .tyc-extend-set {
                 border-top: 1px solid #ccc;
                 margin-top: 10px;
                 padding-top: 10px;
                 display: none; /* Hidden by default */
                 flex-direction: column;
                 gap: 10px;
             }
              .tyc-extend-set.visible { display: flex; }

             .tyc-extend-set-container {
                 border: 1px solid #ddd;
                 padding: 10px;
                 border-radius: 4px;
                 background-color: #f8f8f8;
            }

             .tyc-autobigimg-set .tyc-abi-title {
                 display: flex;
                 justify-content: space-between;
                 align-items: center;
                 margin-bottom: 10px;
                 padding-bottom: 5px;
                 border-bottom: 1px solid #ddd;
                 font-weight: bold;
             }
             .tyc-autobigimg-set .tyc-abi-title button { font-size: 11px; padding: 3px 6px; height: auto; }

            .tyc-set-domain {
                 border: 1px solid #ccc;
                 padding: 8px;
                 margin-bottom: 10px;
                 max-height: 200px; /* Limit height */
                 overflow-y: auto;
                 background-color: #fff;
                 display: flex;
                 flex-direction: column;
                 gap: 5px;
             }
              .tyc-set-domain-default { display: none; } /* Default rules hidden initially */
               .tyc-set-domain-default.visible { display: flex; }

            .tyc-set-replacerule {
                 display: flex;
                 align-items: center;
                 gap: 8px;
                 flex-wrap: wrap; /* Allow wrap within rule */
                 padding: 3px 0;
            }
             .tyc-set-replacerule input[type="text"] {
                 flex-grow: 1; /* Allow text inputs to grow */
                 min-width: 150px;
                 font-size: 11px;
             }
             .tyc-set-replacerule input[type="checkbox"] { flex-shrink: 0; }
              .tyc-set-replacerule .tyc-default-tip {
                 font-size: 10px;
                 color: #777;
                 margin-left: auto; /* Push tip to the right */
                 padding-left: 10px;
             }

             .tyc-status-tip {
                 font-weight: bold;
                 color: #333;
            }

             /* Big Image Preview */
             .tyc-show-big-image {
                 position: fixed;
                 inset: 0; /* Cover viewport */
                 background-color: rgba(0, 0, 0, 0.8); /* Dark overlay */
                 display: flex;
                 justify-content: center;
                 align-items: center;
                 z-index: 2147483647; /* Highest z-index */
                 padding: 20px;
                 cursor: pointer; /* Click anywhere to close */
             }
             .tyc-show-big-image img {
                 max-width: 95vw;
                 max-height: 95vh;
                 object-fit: contain;
                 display: block;
                 border: 3px solid white;
                 border-radius: 4px;
                 background-color: white; /* In case of transparent images */
             }
             .tyc-extend-btn svg { transition: transform 0.3s ease; }
             .tyc-extend-btn.extend-open svg { transform: rotate(180deg); }
             #tycfileElem { display: none; } /* Hide file input */
         `;

        const html = `
            <style>${css}</style>
            <div class="tyc-image-container">
                <div class="tyc-control-section">
                    <!-- Row 1: Main Actions & Info -->
                    <div class="tyc-control-row">
                        <input class="tyc-select-all tyc-input-checkbox" type="checkbox" id="tyc-select-all-cb">
                        <label for="tyc-select-all-cb" class="tyc-label">${langSet.selectAll}</label>
                        <button class="tyc-btn-download">${langSet.downloadBtn}</button>
                        <button class="tyc-btn-zipDownload">${langSet.zipDownloadBtn}</button>
                        <button class="tyc-download-url-btn">${langSet.downloadUrlFile}</button>
                         <span class="tyc-status-tip"></span>
                        <span style="margin-left: auto;">${langSet.inputFilenameTip}:</span>
                        <input type="text" class="tyc-file-name" value="${downloadFileNameBase}" style="width: 150px;">
                         <span title="${langSet.shortcutInfo}">${langSet.shortcutInfo}:</span>
                        <input type="text" class="tyc-shortCutString" value="${shortCutString}" style="width: 80px;">
                        <button class="tyc-btn-close" title="Close (Esc)">X</button>
                     </div>

                     <!-- Row 2: Filters & Toggles -->
                     <div class="tyc-control-row">
                        <div class="tyc-filter-group">
                             <input type="checkbox" class="tyc-width-check tyc-input-checkbox" id="tyc-width-check-cb">
                            <label for="tyc-width-check-cb" class="tyc-label">${langSet.filterWidth}</label>
                            <input type="number" class="tyc-width-value-min" min="0" max="99999" value="0">
                            <span>-</span>
                             <input type="number" class="tyc-width-value-max" min="0" max="99999" value="99999">px
                        </div>
                         <div class="tyc-filter-group">
                             <input type="checkbox" class="tyc-height-check tyc-input-checkbox" id="tyc-height-check-cb">
                            <label for="tyc-height-check-cb" class="tyc-label">${langSet.filterHeight}</label>
                            <input type="number" class="tyc-height-value-min" min="0" max="99999" value="0">
                            <span>-</span>
                            <input type="number" class="tyc-height-value-max" min="0" max="99999" value="99999">px
                         </div>
                        <div class="tyc-extra-grab" title="${langSet.extraGrabTooltip}">
                            <input type="checkbox" class="tyc-extra-grab-check tyc-input-checkbox" id="tyc-extra-grab-cb">
                            <label for="tyc-extra-grab-cb" class="tyc-label">${langSet.extraGrab}</label>
                        </div>
                         <button class="tyc-extend-btn" style="margin-left: auto;">
                            ${langSet.moreSetting}
                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
                                <path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
                             </svg>
                         </button>
                     </div>

                     <!-- Row 3: Extended Settings (Hidden by default) -->
                    <div class="tyc-extend-set">
                        <div class="tyc-autobigimg-set tyc-extend-set-container">
                             <div class="tyc-abi-title">
                                 <span>${langSet.autoBigImgModule}</span>
                                <div> <!-- Group buttons -->
                                    <button class="tyc-default-rule-show">${langSet.defaultSettingRule}</button>
                                    <button class="tyc-export-custom-rule">${langSet.exportCustomRule}</button>
                                    <input type="file" id="tycfileElem" accept="text/plain,.json,.js" style="display:none">
                                    <button class="tyc-import-custom-rule">${langSet.importCustomRule}</button>
                                 </div>
                             </div>
                            <div class="tyc-set-domain tyc-set-domain-default">
                                <!-- Default rules populated by JS -->
                            </div>
                             <div class="tyc-set-domain tyc-set-domain-custom">
                                 <!-- Custom rules populated by JS -->
                             </div>
                         </div>
                        <!-- Add other extend settings here if needed -->
                     </div>
                 </div>
                <div class="tyc-image-wrapper">
                    <!-- Images populated by JS -->
                 </div>
            </div>
        `;

        document.body.insertAdjacentHTML("beforeend", html); // Use beforeend to ensure it's appended last

        // Populate rules after creating the container elements
         autoBigImage.showDefaultRules();
         autoBigImage.showRules("tyc-set-domain-custom", "userRules", "userRulesChecked", "tyc-custom-active");
    }

    /**
     * Attaches event listeners to the UI elements.
     */
    function attachEventListeners() {
        const container = document.querySelector(".tyc-image-container");
        if (!container) return;

        // Close button
        container.querySelector(".tyc-btn-close").addEventListener('click', closePanel);

        // Global keydown listener for Esc
        document.addEventListener('keydown', handleEscKey);

        // Select All checkbox
        container.querySelector(".tyc-select-all").addEventListener('change', handleSelectAllChange);

        // Download buttons
        container.querySelector(".tyc-btn-download").addEventListener('click', handleDownloadSelected);
        container.querySelector(".tyc-btn-zipDownload").addEventListener('click', handleZipDownloadSelected);
        container.querySelector(".tyc-download-url-btn").addEventListener('click', handleDownloadUrlList);

        // Filter and toggle checkboxes/inputs
        container.querySelector(".tyc-width-check").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-height-check").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-extra-grab-check").addEventListener('change', handleExtraGrabChange);
        container.querySelector(".tyc-width-value-min").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-width-value-max").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-height-value-min").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-height-value-max").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-file-name").addEventListener('change', handleFilenameChange);
        container.querySelector(".tyc-shortCutString").addEventListener('change', handleShortcutChange);


        // More Settings toggle
        container.querySelector(".tyc-extend-btn").addEventListener('click', toggleExtendSettings);

        // Auto Big Image Rule Controls
        container.querySelector(".tyc-default-rule-show").addEventListener('click', autoBigImage.onclickShowDefaultBtn);
        container.querySelector(".tyc-export-custom-rule").addEventListener('click', autoBigImage.exportCustomRules);
        container.querySelector(".tyc-import-custom-rule").addEventListener('click', () => document.getElementById('tycfileElem')?.click());
        document.getElementById('tycfileElem')?.addEventListener('change', autoBigImage.getCustomRules.bind(autoBigImage)); // Bind 'this'

         // Listener delegation for rule checkboxes (attach to the containers)
         const defaultRuleContainer = container.querySelector('.tyc-set-domain-default');
         const customRuleContainer = container.querySelector('.tyc-set-domain-custom');
         if (defaultRuleContainer) {
             defaultRuleContainer.addEventListener('change', (e) => {
                 if (e.target.matches('.tyc-default-active')) {
                     autoBigImage.oncheckChange();
                 }
             });
         }
         if (customRuleContainer) {
              customRuleContainer.addEventListener('change', (e) => {
                  if (e.target.matches('.tyc-custom-active')) {
                      autoBigImage.oncheckChangeCustom();
                  }
              });
         }


        // Listener delegation for image clicks and actions within the wrapper
        const imageWrapper = container.querySelector(".tyc-image-wrapper");
        if (imageWrapper) {
            imageWrapper.addEventListener('click', handleImageWrapperClick);
        }
    }

    /**
     * Handles clicks within the image wrapper for selection, fullscreen, and single download.
     */
    function handleImageWrapperClick(event) {
        const target = event.target;
        const imgItemContainer = target.closest('.tyc-img-item-container'); // Find parent container

        if (!imgItemContainer) return; // Clicked outside an image item

        const imageIndex = parseInt(imgItemContainer.dataset.index, 10);
        if (isNaN(imageIndex)) return; // Should not happen

        // Click on action buttons
        if (target.closest('.tyc-action-fullscreen')) {
            showBigImagePreview(filteredImgUrls[imageIndex]);
        } else if (target.closest('.tyc-action-download')) {
            downloadSingleImage(filteredImgUrls[imageIndex], imageIndex);
        }
        // Click on the image preview itself for selection
        else if (target.matches('.tyc-image-preview')) {
            toggleImageSelection(imageIndex, imgItemContainer);
        }
    }

    /**
      * Updates the UI based on the current state (filters, selection).
      */
    function initUI() {
        console.log("Initializing UI...");
        if (!document.querySelector(".tyc-image-container")) {
            console.error("UI container not found during init.");
            return;
        }

        // 1. Apply Filters & Auto Big Image
        applyFiltersAndRules();

        // 2. Reset Selection State (important when filters change)
        imgSelectedIndices = [];
        // zipBase64Sources = {}; // Keep fetched base64 unless explicitly cleared

        // 3. Render Images
        renderImages();

        // 4. Update Status/Counts
        updateSelectionCount();
        updateTotalCount(); // Update total count based on filtered images

        // 5. Load saved settings into UI elements
        loadSettingsToUI();

         // 6. Fetch Base64 for visible images (optional, could be deferred to Zip button click)
        // fetchBase64ForZip(filteredImgUrls); // Consider performance implications
    }

    /**
     * Resets UI elements and internal state, preparing for a fresh image discovery.
     */
    function cleanUI() {
        const container = document.querySelector(".tyc-image-container");
        if (container) {
             const imageWrapper = container.querySelector(".tyc-image-wrapper");
             if (imageWrapper) imageWrapper.innerHTML = ""; // Clear images
             updateStatusTip(""); // Clear status
             updateSelectionCount(); // Reset count display
             updateTotalCount();
        }
        imgSelectedIndices = [];
        filteredImgUrls = [];
        zipBase64Sources = {};
        isFetchingBase64 = false;
         // Don't clear currentImgUrls here, it's done before discovery
    }

    /**
     * Closes the downloader panel and removes listeners.
     */
    function closePanel() {
        const container = document.querySelector(".tyc-image-container");
        if (container) {
            container.remove();
        }
        // Remove global listener
         document.removeEventListener('keydown', handleEscKey);
         console.log("Image Downloader panel closed.");
    }

    /**
     * Handles the Escape key press to close the panel.
     */
    function handleEscKey(event) {
        if (event.key === "Escape") {
            // Close big image preview first if open
             const bigPreview = document.querySelector(".tyc-show-big-image");
             if (bigPreview) {
                 bigPreview.remove();
            } else {
                 closePanel();
            }
        }
    }

    // --- Event Handler Functions ---

    function handleSelectAllChange(event) {
        const isChecked = event.target.checked;
        imgSelectedIndices = []; // Clear previous selection

        if (isChecked) {
             // Select all indices from 0 to length-1
             for (let i = 0; i < filteredImgUrls.length; i++) {
                 imgSelectedIndices.push(i);
             }
         }
        // Else: imgSelectedIndices remains empty

         // Update UI for all items
         const allItems = document.querySelectorAll('.tyc-img-item-container');
         allItems.forEach((item, index) => {
            if (isChecked) {
                 item.classList.add('selected');
             } else {
                 item.classList.remove('selected');
             }
         });

        updateSelectionCount();
    }

     async function handleDownloadSelected() {
        const urlsToDownload = imgSelectedIndices.map(index => filteredImgUrls[index]).filter(Boolean);
        if (urlsToDownload.length === 0) {
             alert(langSet.selectAlert);
             return;
         }

        const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
        updateStatusTip(langSet.downloadingImages);
        console.log(`Starting download of ${urlsToDownload.length} images with base name: ${baseFilename}`);

         let downloadedCount = 0;
         for (let i = 0; i < urlsToDownload.length; i++) {
             const url = urlsToDownload[i];
             // Determine file extension
             let extension = 'jpg'; // Default
             try {
                 if (url.startsWith('data:image/')) {
                     const match = url.match(/^data:image\/(\w+);/);
                     extension = match ? match[1].replace('jpeg', 'jpg') : 'png'; // Common types
                      if (extension === 'svg+xml') extension = 'svg';
                  } else {
                     const pathname = new URL(url).pathname;
                     const lastDot = pathname.lastIndexOf('.');
                     if (lastDot !== -1) {
                         extension = pathname.substring(lastDot + 1).toLowerCase().split('?')[0]; // Handle query params
                          // Basic sanitation
                          if (!/^[a-z0-9]+$/.test(extension) || extension.length > 5) {
                             extension = 'jpg';
                         }
                     }
                  }
             } catch (e) { console.warn("Could not determine extension for:", url); }


             const filename = `${baseFilename}_${String(i + 1).padStart(3, '0')}.${extension}`;
             try {
                 // console.log(`Downloading: ${url} as ${filename}`);
                 saveAs(url, filename); // Use FileSaver.js
                 downloadedCount++;
                 updateStatusTip(`${langSet.downloadingImages} (${downloadedCount}/${urlsToDownload.length})`);
                 await sleep(200); // Small delay between downloads to avoid browser blocking
             } catch (error) {
                 console.error(`Failed to initiate download for: ${url}`, error);
                  updateStatusTip(`Error downloading image ${i + 1}. Check console.`);
                 await sleep(500); // Longer pause on error
             }
         }
        updateStatusTip(langSet.downloadComplete + ` (${downloadedCount}/${urlsToDownload.length})`);
        console.log("Download process finished.");
    }

    async function handleZipDownloadSelected() {
        const selectedIndicesForZip = [...imgSelectedIndices]; // Copy selection at time of click
        const urlsToZip = selectedIndicesForZip.map(index => filteredImgUrls[index]).filter(Boolean);

         if (urlsToZip.length === 0) {
             alert(langSet.selectAlert);
             return;
         }

        if (isFetchingBase64) {
             alert("Still fetching image data for zipping, please wait.");
             return;
         }

        updateStatusTip(langSet.preparingZip);
        isFetchingBase64 = true;
        console.log(`Starting ZIP process for ${urlsToZip.length} images.`);

        const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
        const zip = new JSZip();
        const zipFolder = zip.folder(baseFilename); // Create folder inside zip named after base filename

        let processedCount = 0;
        const totalToProcess = urlsToZip.length;

        const fetchPromises = urlsToZip.map((url, index) => {
            return new Promise(async (resolve, reject) => {
                 const originalIndex = selectedIndicesForZip[index]; // Keep track of original index for filename
                try {
                    const base64Data = await getBase64ForZip(url); // Fetch or retrieve cached
                    if (base64Data) {
                        let extension = 'jpg';
                        const match = base64Data.match(/^data:image\/(\w+);/);
                         extension = match ? match[1].replace('jpeg', 'jpg') : 'png';
                         if (extension === 'svg+xml') extension = 'svg';

                        const filename = `${String(originalIndex + 1).padStart(3, '0')}.${extension}`; // Use original order index
                        zipFolder.file(filename, base64Data.split(',')[1], { base64: true });
                    } else {
                         console.warn(`Skipping invalid/unfetchable URL for ZIP: ${url}`);
                    }
                 } catch (error) {
                    console.error(`Error processing URL for ZIP: ${url}`, error);
                 } finally {
                     processedCount++;
                     updateStatusTip(`${langSet.preparingZip} (${processedCount}/${totalToProcess})`);
                     resolve(); // Resolve promise even on failure to not block Promise.all
                 }
             });
         });

         try {
             await Promise.all(fetchPromises); // Wait for all fetches/conversions

             if (Object.keys(zipFolder.files).length === 0) {
                alert("No images could be added to the ZIP file. Check console for errors.");
                updateStatusTip("ZIP creation failed.");
                 return;
            }

             updateStatusTip(langSet.zipReady + " Generating file...");
             console.log("Generating ZIP blob...");

             zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } })
                 .then(function (content) {
                     console.log("ZIP blob generated, saving...");
                     saveAs(content, `${baseFilename}.zip`);
                     updateStatusTip("ZIP download initiated!");
                     // Optionally clear cache if needed: zipBase64Sources = {};
                 })
                 .catch(err => {
                     console.error("Error generating ZIP:", err);
                     alert(`Error generating ZIP file: ${err.message}`);
                     updateStatusTip("ZIP generation failed.");
                 });
         } catch (error) {
             console.error("Error during ZIP processing:", error);
             updateStatusTip("ZIP creation failed.");
         } finally {
             isFetchingBase64 = false;
         }
    }

    /**
      * Fetches an image URL and returns its Base64 representation. Uses cache.
      */
     function getBase64ForZip(url) {
         return new Promise((resolve, reject) => {
             if (!url || typeof url !== 'string') {
                 resolve(null); // Invalid URL
                 return;
             }

             // Return immediately if it's already a Base64 string
             if (url.startsWith('data:image/')) {
                 resolve(url);
                 return;
             }

             // Check cache
             if (zipBase64Sources[url]) {
                 resolve(zipBase64Sources[url]);
                 return;
             }

             // Fetch using GM_xmlhttpRequest for CORS
             try {
                 GM_xmlhttpRequest({
                     method: "GET",
                     url: url,
                     responseType: "blob",
                      headers: {
                        // Add Referer if needed, often helps
                        Referer: window.location.origin
                    },
                     onload: function (response) {
                         if (response.status >= 200 && response.status < 300) {
                             const blob = response.response;
                             const reader = new FileReader();
                             reader.onloadend = function () {
                                 const base64 = reader.result;
                                 if (typeof base64 === 'string' && base64.startsWith('data:image')) {
                                      zipBase64Sources[url] = base64; // Cache result
                                      resolve(base64);
                                 } else {
                                     console.warn("Failed to read blob as base64 for:", url);
                                     resolve(null); // Indicate failure to convert
                                 }
                             };
                             reader.onerror = function(e) {
                                 console.error("FileReader error for blob:", url, e);
                                 resolve(null); // Indicate read failure
                             };
                             reader.readAsDataURL(blob);
                         } else {
                             console.warn(`GM_xmlhttpRequest failed for ${url}, status: ${response.status}`);
                             resolve(null); // Indicate fetch failure
                         }
                     },
                     onerror: function (error) {
                         console.error(`GM_xmlhttpRequest error for ${url}:`, error);
                         resolve(null); // Indicate network error
                     },
                     ontimeout: function() {
                        console.warn(`GM_xmlhttpRequest timed out for ${url}`);
                         resolve(null);
                    }
                 });
             } catch (e) {
                 console.error(`Error initiating GM_xmlhttpRequest for ${url}:`, e);
                 resolve(null); // Indicate initiation error
             }
         });
     }

    function handleDownloadUrlList() {
        const urlsToDownload = imgSelectedIndices.map(index => filteredImgUrls[index]).filter(Boolean);
         if (urlsToDownload.length === 0) {
             alert(langSet.selectAlert);
             return;
         }

        const urlText = urlsToDownload.join("\n");
        const blob = new Blob([urlText], { type: "text/plain;charset=utf-8" });
         const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
        saveAs(blob, `${baseFilename}_urls.txt`);
    }

    function handleFilterChange() {
        // Save the setting
        const target = event.target;
        if (target.type === 'checkbox') {
            GM_setValue(target.classList[0], target.checked); // e.g., tyc-width-check
        } else if (target.type === 'number') {
            GM_setValue(target.classList[0], target.value); // e.g., tyc-width-value-min
        }
        // Re-initialize the UI to apply filters
        initUI();
    }

     function handleExtraGrabChange(event) {
         const isChecked = event.target.checked;
         GM_setValue('tyc-extra-grab-check', isChecked);
         alert(langSet.extraGrabTooltip); // Inform user about refresh requirement
         // Actual enabling/disabling happens on page load based on saved value
     }


    function handleFilenameChange(event) {
         downloadFileNameBase = event.target.value;
         // No need to save this to GM_setValue unless persistence is desired
    }

    function handleShortcutChange(event) {
        const newShortcut = event.target.value.toLowerCase();
        if (newShortcut && newShortcut !== shortCutString) {
             try {
                 // Unbind old shortcut
                 hotkeys.unbind(shortCutString, shortcutFunction);
                 // Bind new shortcut
                 hotkeys(newShortcut, shortcutFunction);
                 shortCutString = newShortcut;
                 GM_setValue("shortCutString", shortCutString);
                 console.log("Shortcut updated to:", shortCutString);
             } catch (e) {
                 console.error("Failed to update shortcut:", e);
                 alert(`Failed to set shortcut "${newShortcut}". It might be invalid or already in use.`);
                 // Revert UI and variable
                 event.target.value = shortCutString;
             }
         } else {
            event.target.value = shortCutString; // Revert if invalid or same
        }
    }

    function toggleExtendSettings() {
        const extendSet = document.querySelector(".tyc-extend-set");
        const button = document.querySelector(".tyc-extend-btn");
         const svg = button.querySelector('svg');

        if (extendSet && button && svg) {
             const isOpen = extendSet.classList.toggle('visible');
             button.classList.toggle('extend-open', isOpen);
             svg.style.transform = isOpen ? 'rotate(180deg)' : 'rotate(0deg)';
              button.querySelector('span').textContent = isOpen ? langSet.fold : langSet.moreSetting;

             // Adjust image wrapper margin-top dynamically
             adjustWrapperMargin();
         }
    }

    /**
     * Toggles the selection state of an image.
     */
    function toggleImageSelection(index, containerElement) {
         const selectedIndexPosition = imgSelectedIndices.indexOf(index);

         if (selectedIndexPosition > -1) {
             // Deselect
             imgSelectedIndices.splice(selectedIndexPosition, 1);
             containerElement.classList.remove('selected');
         } else {
             // Select
             imgSelectedIndices.push(index);
             containerElement.classList.add('selected');
         }

        updateSelectionCount();
        // Update "Select All" checkbox state
        const selectAllCheckbox = document.querySelector('.tyc-select-all');
         if (selectAllCheckbox) {
             selectAllCheckbox.checked = imgSelectedIndices.length === filteredImgUrls.length && filteredImgUrls.length > 0;
         }
    }

     // --- UI Update Functions ---

     function adjustWrapperMargin() {
          const controlSection = document.querySelector('.tyc-control-section');
          const imageWrapper = document.querySelector('.tyc-image-wrapper');
          if (controlSection && imageWrapper) {
              // Setting margin directly might conflict with fixed positioning. Padding might be better.
              // Let's recalculate necessary top padding/margin for the wrapper
              // Since control section is sticky, wrapper just needs enough space below it.
              // Using padding on the container might be better.
              const container = document.querySelector('.tyc-image-container');
              if (container) {
                  const controlHeight = controlSection.offsetHeight;
                  container.style.paddingTop = `${controlHeight}px`;
              }
          }
      }

      function updateStatusTip(message) {
          const tipElement = document.querySelector(".tyc-status-tip");
          if (tipElement) {
              tipElement.textContent = message;
          }
      }

    function updateSelectionCount() {
        const count = imgSelectedIndices.length;
        const total = filteredImgUrls.length;
         const message = `${langSet.fetchDoneTip1Type2}${count}/${total}${langSet.fetchDoneTip2}`;
         updateStatusTip(message); // Use main status tip area
    }

    function updateTotalCount() {
        // Could add a separate element or prepend to status tip
        // Example: Prepending to status tip (might get overwritten)
        // const totalMessage = `${langSet.totalFound}${filteredImgUrls.length}${langSet.images}. `;
        // const currentTip = document.querySelector(".tyc-status-tip")?.textContent || '';
        // updateStatusTip(totalMessage + currentTip);
        // Or just rely on the selection count's total part.
    }


    /**
     * Loads saved settings from GM_setValue into the UI elements.
     */
    function loadSettingsToUI() {
        const container = document.querySelector(".tyc-image-container");
        if (!container) return;

        // Filters
        container.querySelector(".tyc-width-check").checked = GM_getValue("tyc-width-check", false);
        container.querySelector(".tyc-height-check").checked = GM_getValue("tyc-height-check", false);
        container.querySelector(".tyc-width-value-min").value = GM_getValue("tyc-width-value-min", "0");
        container.querySelector(".tyc-width-value-max").value = GM_getValue("tyc-width-value-max", "99999");
        container.querySelector(".tyc-height-value-min").value = GM_getValue("tyc-height-value-min", "0");
        container.querySelector(".tyc-height-value-max").value = GM_getValue("tyc-height-value-max", "99999");

         // Extra Grab
         container.querySelector(".tyc-extra-grab-check").checked = GM_getValue("tyc-extra-grab-check", false);

        // Shortcut
        container.querySelector(".tyc-shortCutString").value = shortCutString; // Already loaded into variable
    }

     /**
      * Applies filters (width/height) and auto-big-image rules to currentImgUrls.
      */
      function applyFiltersAndRules() {
          // 1. Start with all discovered URLs
          let tempFiltered = [...currentImgUrls];

          // 2. Apply Auto Big Image rules (potentially adds more URLs)
          tempFiltered = autoBigImage.getBigImageArray(tempFiltered);
          console.log(`After AutoBigImage: ${tempFiltered.length} URLs`);


          // 3. Apply Dimension Filters (Width/Height)
          // Need to load images temporarily to check dimensions - this can be slow!
          // Consider adding a loading state or doing this async?
          // For now, implement synchronously as in the original.
          const checkWidth = GM_getValue("tyc-width-check", false);
          const minWidth = parseInt(GM_getValue("tyc-width-value-min", "0"), 10);
          const maxWidth = parseInt(GM_getValue("tyc-width-value-max", "99999"), 10);
          const checkHeight = GM_getValue("tyc-height-check", false);
          const minHeight = parseInt(GM_getValue("tyc-height-value-min", "0"), 10);
          const maxHeight = parseInt(GM_getValue("tyc-height-value-max", "99999"), 10);

           if (checkWidth || checkHeight) {
               console.log("Applying dimension filters...");
               // This synchronous filtering can lock the UI for many images.
               // An async approach would be better for UX but more complex.
                tempFiltered = tempFiltered.filter(url => {
                   try {
                        // Skip filtering for invalid URLs
                       if (!url || typeof url !== 'string' || (!url.startsWith('http') && !url.startsWith('data:'))) {
                            return true; // Keep potentially invalid URLs for now? Or filter? Let's keep.
                        }

                       // Create temporary image - may not work reliably for all URLs in a script context
                       // without actually adding to DOM or waiting for onload.
                       // This part is inherently unreliable without async loading checks.
                       // We'll use naturalWidth/Height which might be 0 if not loaded.
                       const img = new Image();
                       img.src = url; // Assign src, but dimension might not be available yet

                       // Warning: The following check relies on browser caching or immediate dimension availability,
                       // which is NOT guaranteed. This filtering step is inherently flawed without async handling.
                        let width = img.naturalWidth || img.width; // Use naturalWidth if available
                        let height = img.naturalHeight || img.height;

                        // Very basic check: if dimensions are 0, maybe skip filtering it?
                        // Or assume it passes? Let's assume it passes if dimensions are unknown (0).
                        if (width === 0 && height === 0 && !url.startsWith('data:')) {
                             // console.log(`Dimensions unknown for ${url}, keeping.`);
                             return true;
                         }

                        const widthOk = !checkWidth || (width >= minWidth && width <= maxWidth);
                        const heightOk = !checkHeight || (height >= minHeight && height <= maxHeight);

                       // If dimensions were resolved and filters applied:
                       // console.log(`Filtering ${url}: ${width}x${height} -> WidthOK:${widthOk}, HeightOK:${heightOk}`);

                       return widthOk && heightOk;
                   } catch (e) {
                       console.warn(`Error filtering image by dimension: ${url}`, e);
                       return true; // Keep image if filtering fails
                   }
               });
               console.log(`After dimension filters: ${tempFiltered.length} URLs`);
           }

           // 4. Final unique URLs
           filteredImgUrls = Array.from(new Set(tempFiltered)).filter(Boolean);
           console.log(`Final filtered count: ${filteredImgUrls.length}`);
       }


    /**
     * Renders the filtered images in the UI.
     */
    function renderImages() {
        const imageWrapper = document.querySelector(".tyc-image-wrapper");
        if (!imageWrapper) return;

        imageWrapper.innerHTML = ''; // Clear previous images
        const fragment = document.createDocumentFragment();

        if (filteredImgUrls.length === 0) {
            imageWrapper.textContent = "No images found matching criteria.";
            return;
        }

        filteredImgUrls.forEach((imgUrl, index) => {
             if (!imgUrl || typeof imgUrl !== 'string') return; // Skip invalid entries

            const itemContainer = document.createElement('div');
            itemContainer.className = 'tyc-img-item-container';
            itemContainer.dataset.index = index; // Store index

            const imgPreview = document.createElement('img');
            imgPreview.className = 'tyc-image-preview';
            imgPreview.loading = 'lazy'; // Lazy load images
             imgPreview.dataset.loaded = 'false';

             // Handle image loading and error states
             imgPreview.onload = () => {
                 imgPreview.dataset.loaded = 'true'; // Mark as loaded
                 const dimensions = `${imgPreview.naturalWidth}x${imgPreview.naturalHeight}`;
                  const dimElement = itemContainer.querySelector('.tyc-image-dimensions');
                  if (dimElement) dimElement.textContent = dimensions;
                  // Maybe re-apply filters here if needed based on loaded dimensions? Complex.
              };
              imgPreview.onerror = () => {
                  imgPreview.alt = 'Image failed to load';
                  imgPreview.dataset.loaded = 'error'; // Mark as error
                  // itemContainer.style.display = 'none'; // Option: hide broken images
                  const dimElement = itemContainer.querySelector('.tyc-image-dimensions');
                   if (dimElement) dimElement.textContent = 'Error';
                   // Add a visual indicator for error
                   imgPreview.style.filter = 'grayscale(100%) opacity(50%)';
                   imgPreview.style.border = '2px dashed red';

              };

             // Set src *after* attaching listeners
              imgPreview.src = imgUrl;

            itemContainer.appendChild(imgPreview);

            // Info container
            const infoContainer = document.createElement('div');
            infoContainer.className = 'tyc-image-info-container';

            // Dimensions placeholder
            const dimensionsSpan = document.createElement('span');
            dimensionsSpan.className = 'tyc-image-dimensions';
             dimensionsSpan.textContent = 'Loading...'; // Placeholder until onload

            // Action buttons
            const actionsDiv = document.createElement('div');
            actionsDiv.className = 'tyc-img-actions';
            actionsDiv.innerHTML = `
                <button class="tyc-action-fullscreen" title="View Fullscreen">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/></svg>
                </button>
                <button class="tyc-action-download" title="Download This Image">
                     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
                </button>
            `;

            infoContainer.appendChild(dimensionsSpan);
            infoContainer.appendChild(actionsDiv);
            itemContainer.appendChild(infoContainer);

            fragment.appendChild(itemContainer);
        });

        imageWrapper.appendChild(fragment);
        adjustWrapperMargin(); // Adjust margin after rendering
    }


    /**
     * Shows the large image preview overlay.
     */
    function showBigImagePreview(imageUrl) {
        // Remove existing preview first
        const existingPreview = document.querySelector(".tyc-show-big-image");
        if (existingPreview) {
            existingPreview.remove();
        }

        const previewContainer = document.createElement('div');
        previewContainer.className = 'tyc-show-big-image';
        previewContainer.title = 'Click to close'; // Tooltip

        const img = document.createElement('img');
        img.src = imageUrl;
        img.alt = 'Large Preview';

        previewContainer.appendChild(img);

        // Click anywhere on the overlay (or the image) to close
        previewContainer.addEventListener('click', () => {
            previewContainer.remove();
        });

        document.body.appendChild(previewContainer);
    }

    /**
     * Downloads a single image using FileSaver.
     */
    function downloadSingleImage(url, index) {
        if (!url) return;
        const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
         // Determine file extension (same logic as batch download)
         let extension = 'jpg';
         try {
             if (url.startsWith('data:image/')) {
                 const match = url.match(/^data:image\/(\w+);/);
                 extension = match ? match[1].replace('jpeg', 'jpg') : 'png';
                 if (extension === 'svg+xml') extension = 'svg';
             } else {
                 const pathname = new URL(url).pathname;
                 const lastDot = pathname.lastIndexOf('.');
                 if (lastDot !== -1) {
                     extension = pathname.substring(lastDot + 1).toLowerCase().split('?')[0];
                      if (!/^[a-z0-9]+$/.test(extension) || extension.length > 5) extension = 'jpg';
                  }
             }
         } catch (e) { console.warn("Could not determine extension for single download:", url); }

        const filename = `${baseFilename}_${String(index + 1).padStart(3, '0')}.${extension}`;
        try {
            console.log(`Downloading single: ${url} as ${filename}`);
            saveAs(url, filename);
         } catch (error) {
             console.error(`Failed to initiate download for: ${url}`, error);
             alert(`Failed to download image: ${error.message}`);
         }
    }


    // --- Main Execution Flow ---

    /**
     * The main function called by the menu command or shortcut.
     */
    async function wrapper() {
        console.log("Image Downloader activated.");

        // 1. Setup initial state and variables
        setupVariables();

        // 2. Check if panel already exists, toggle if it does
        const existingPanel = document.querySelector(".tyc-image-container");
        if (existingPanel) {
            closePanel();
            return;
        }

        // 3. Create the basic UI structure
        createUI();

        // 4. Discover images (including async canvas handling)
        updateStatusTip(langSet.fetchTip); // Initial status
        await discoverImages(); // Wait for discovery (including canvas) to complete

        // 5. Initialize UI (apply filters, render images, load settings)
        initUI(); // This now applies filters, renders, and updates counts

        // 6. Attach all event listeners
        attachEventListeners();

        console.log("Image Downloader panel ready.");
    }

    /**
     * Function called by the shortcut key.
     */
    function shortcutFunction(event, handler) {
        event.preventDefault(); // Prevent default browser action for the shortcut
        wrapper();
    }

    // --- Script Initialization ---

    // Register menu command
    GM_registerMenuCommand(langSet.downloadMenuText, wrapper);

    // Register shortcut
    try {
        shortCutString = GM_getValue("shortCutString") || "alt+w";
        hotkeys(shortCutString, shortcutFunction);
     } catch(e) {
         console.error("Failed to register shortcut:", e);
         // Fallback or default if hotkeys lib failed or shortcut invalid
         shortCutString = "alt+w";
         try { hotkeys(shortCutString, shortcutFunction); } catch(e2) { console.error("Fallback shortcut failed too:", e2); }
    }


    // Enable 'Extra Grab' on page load if the setting is checked
    if (GM_getValue("tyc-extra-grab-check", false)) {
        enableExtraGrab();
    } else {
        // Ensure it's disabled if setting is off (in case it was left on)
        // This requires the script to run early enough, @run-at document-start might be better
        // but can cause issues finding elements. Sticking with document-end for now.
        // disableExtraGrab(); // This might be too late if script runs at document-end
    }

     // Clean up listener on unload? Tampermonkey usually handles this, but good practice:
     window.addEventListener('unload', () => {
         try { hotkeys.unbind(shortCutString, shortcutFunction); } catch(e) {}
         document.removeEventListener('keydown', handleEscKey);
         if (GM_getValue("tyc-extra-grab-check", false)) {
             // Attempt to restore original descriptor on unload, might not always work
             // disableExtraGrab();
         }
     });

})();