// ==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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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();
}
});
})();