Collect & bulk-copy image URLs. Auto-fetch via Load More / Scroll / Pagination. Detects <img>, lazy-loads, CSS background-images. Click 🖼️ at bottom-right.
// ==UserScript==
// @name Bulk Image Copy Sidebar
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Collect & bulk-copy image URLs. Auto-fetch via Load More / Scroll / Pagination. Detects <img>, lazy-loads, CSS background-images. Click 🖼️ at bottom-right.
// @author You
// @match *://*/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ===========================================
// 1. STATE
// ===========================================
const state = {
images: new Map(),
seenSet: new Set(),
isFetching: false,
fetchTarget: 0,
fetchStopped: false,
minWidth: 0,
minHeight: 0,
extFilter: '',
domainFilter: '',
searchQuery: '',
sidebarVisible: false,
lastUrl: location.href,
_mouseX: 0,
_mouseY: 0,
_paginationUrl: null
};
const SB_WIDTH = 420;
// ===========================================
// 2. HELPERS
// ===========================================
function normalizeUrl(url) {
try {
const u = new URL(url);
u.hash = '';
return u.href.replace(/\/$/, '');
} catch { return url; }
}
function extractUrlsFromCss(cssVal) {
if (!cssVal || !/url\(/i.test(cssVal)) return [];
const urls = [];
const re = /url\(\s*["']?\s*([^"'\s)]+)\s*["']?\s*\)/gi;
let m;
while ((m = re.exec(cssVal)) !== null) {
const u = m[1].trim();
if (u && !u.startsWith('data:')) urls.push(u);
}
return urls;
}
function getFilenameFromUrl(url) {
try { return new URL(url).pathname.split('/').pop() || url; } catch { return url; }
}
function escape(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
function dim(n) { return n > 0 ? n : '—'; }
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function isImageUrl(str) {
if (typeof str !== 'string' || str.length < 10) return false;
if (!str.startsWith('http://') && !str.startsWith('https://') && !str.startsWith('//')) return false;
const path = str.split('?')[0].split('#')[0];
const ext = path.split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif'].indexOf(ext) !== -1;
}
function findAllImageUrls(obj, depth) {
depth = depth || 0;
var results = new Set();
if (depth > 8 || !obj || typeof obj !== 'object') return results;
if (Array.isArray(obj)) {
for (var i = 0; i < obj.length; i++) {
var sub = findAllImageUrls(obj[i], depth + 1);
sub.forEach(function (u) { results.add(u); });
}
} else {
for (var key in obj) {
var val = obj[key];
if (typeof val === 'string' && isImageUrl(val)) {
results.add(val);
} else if (typeof val === 'object' && val !== null) {
var sub = findAllImageUrls(val, depth + 1);
sub.forEach(function (u) { results.add(u); });
}
}
}
return results;
}
var _dimQueue = {}; // prevent duplicate dimension fetches
function enrichImageDimensions(normUrl) {
var entry = state.images.get(normUrl);
if (!entry || (entry.width > 0 && entry.height > 0)) return;
if (_dimQueue[normUrl]) return;
_dimQueue[normUrl] = true;
var img = new Image();
img.onload = function () {
entry.width = img.naturalWidth || img.width || 0;
entry.height = img.naturalHeight || img.height || 0;
delete _dimQueue[normUrl];
renderSidebar();
};
img.onerror = function () { delete _dimQueue[normUrl]; };
img.src = normUrl;
}
function addImageToState(url, source, alt, w, h) {
if (!url || url.startsWith('data:')) return false;
if (url.startsWith('//')) url = location.protocol + url;
var norm = normalizeUrl(url);
if (state.seenSet.has(norm)) return false;
state.seenSet.add(norm);
state.images.set(norm, {
url: norm,
source: source || 'img',
alt: alt || '',
width: w || 0,
height: h || 0,
timestamp: Date.now(),
selected: false
});
// Enrich dimensions in background for images without known size
if ((w || 0) === 0 && (h || 0) === 0) enrichImageDimensions(norm);
return true;
}
// ===========================================
// 3. IMAGE HARVEST ENGINE
// ===========================================
function harvestImages(rootNode) {
const root = rootNode || document;
let added = 0;
const now = Date.now();
function addImage(url, source, alt, w, h) {
if (addImageToState(url, source, alt, w, h)) added++;
}
// --- A: <img> tags ---
const imgEls = root.querySelectorAll('img');
for (const img of imgEls) {
let url = img.getAttribute('content')
|| img.getAttribute('src')
|| img.getAttribute('data-src')
|| img.getAttribute('data-lazy-src')
|| img.getAttribute('data-original')
|| img.getAttribute('data-highres')
|| '';
if (!url && img.srcset) {
const first = img.srcset.split(',')[0].trim().split(' ')[0];
if (first) url = first;
}
if (url) {
addImage(url, 'img', img.alt,
img.naturalWidth || img.width || 0,
img.naturalHeight || img.height || 0);
}
}
// --- B: <picture> <source> elements ---
const sourceEls = root.querySelectorAll('picture source');
for (const s of sourceEls) {
const srcset = s.getAttribute('srcset');
if (srcset) {
const first = srcset.split(',')[0].trim().split(' ')[0];
if (first) addImage(first, 'picture', '', 0, 0);
}
}
// --- C: CSS background-image (inline + computed) ---
try {
const bgEls = root.querySelectorAll('[style*="background"]');
let bgCount = 0;
for (const el of bgEls) {
if (bgCount > 300) break;
let cssVal = '';
if (el.style && el.style.backgroundImage && el.style.backgroundImage !== 'none') {
cssVal = el.style.backgroundImage;
} else if (el.style && el.style.background && /url\(/i.test(el.style.background)) {
cssVal = el.style.background;
}
if (!cssVal || !/url\(/i.test(cssVal)) {
try {
const comp = getComputedStyle(el).backgroundImage;
if (comp && comp !== 'none' && /url\(/i.test(comp)) cssVal = comp;
} catch (e) { /* skip */ }
}
if (cssVal && /url\(/i.test(cssVal)) {
const urls = extractUrlsFromCss(cssVal);
for (const url of urls) {
addImage(url, 'bg', '', 0, 0);
bgCount++;
}
}
}
} catch (e) {
console.warn('[ImgCopy] CSS bg scan error:', e);
}
return added;
}
// ===========================================
// 4. IMAGE API HOOK (intercept fetch/XHR for JSON API responses)
// ===========================================
var _hookInstalled = false;
var _apiCallPatterns = []; // { key, baseUrl, param, currentValue } for auto-fetch
function recordApiCallPattern(requestUrl) {
if (!requestUrl || typeof requestUrl !== 'string') return;
try {
var url = new URL(requestUrl, location.origin);
var pageParams = ['page', 'p', 'offset', 'start', 'cursor', 'index', 'skip', 'from', 'pos'];
for (var pi = 0; pi < pageParams.length; pi++) {
var param = pageParams[pi];
if (url.searchParams.has(param)) {
var val = url.searchParams.get(param);
var numVal = parseInt(val, 10);
if (!isNaN(numVal)) {
var search = url.search;
var patternKey = url.origin + url.pathname;
// Remove existing pattern with same key
for (var i = _apiCallPatterns.length - 1; i >= 0; i--) {
if (_apiCallPatterns[i].key === patternKey) _apiCallPatterns.splice(i, 1);
}
_apiCallPatterns.push({
key: patternKey,
baseUrl: url.origin + url.pathname + search,
param: param,
currentValue: numVal
});
return;
}
}
}
// If no numeric page param found, try detecting page in path: /page/2 or /p/2
var pathMatch = url.pathname.match(/\/(?:page|p|offset|start)\/(\d+)/i);
if (pathMatch) {
var numVal2 = parseInt(pathMatch[1], 10);
if (!isNaN(numVal2)) {
var patternKey2 = url.pathname.replace(/\/(?:page|p|offset|start)\/\d+/i, '/:page');
var baseKey = url.origin + patternKey2;
var fullBase = url.origin + url.pathname.replace(/\/\d+\/?$/, '/') + url.search;
for (var j = _apiCallPatterns.length - 1; j >= 0; j--) {
if (_apiCallPatterns[j].key === baseKey) _apiCallPatterns.splice(j, 1);
}
_apiCallPatterns.push({
key: baseKey,
baseUrl: fullBase,
param: '_path_',
currentValue: numVal2,
pathPattern: url.pathname.replace(/\d+/, ':num')
});
}
}
} catch (e) { /* ignore invalid URLs */ }
}
function setupAPIHooks() {
if (_hookInstalled) return;
_hookInstalled = true;
// --- Hook window.fetch ---
var originalFetch = window.fetch;
window.fetch = function (input, init) {
return originalFetch.call(window, input, init).then(function (response) {
try {
var ct = (response.headers && response.headers.get('content-type')) || '';
if (ct.indexOf('json') !== -1) {
var clone = response.clone();
clone.text().then(function (body) {
if (!body || body.length < 50) return;
try {
var data = JSON.parse(body);
var urls = findAllImageUrls(data);
if (urls.size > 0) {
var reqUrl = (typeof input === 'string' ? input : (input && input.url)) || '';
if (reqUrl) recordApiCallPattern(reqUrl);
var added = 0;
urls.forEach(function (url) {
if (addImageToState(url, 'api')) added++;
});
if (added > 0) {
console.log('[ImgCopy] API hook: +' + added + ' images');
renderSidebar();
}
}
} catch (e) { /* not JSON or no image URLs */ }
}).catch(function () {});
}
} catch (e) { /* skip */ }
return response;
});
};
// --- Hook XMLHttpRequest ---
var origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
this._icUrl = (typeof url === 'string') ? url : (url ? url.toString() : '');
return origOpen.apply(this, arguments);
};
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function () {
if (this._icUrl) {
this.addEventListener('load', function () {
try {
var ct = this.getResponseHeader('content-type') || '';
if (ct.indexOf('json') !== -1 && this.responseText && this.responseText.length > 50) {
var data = JSON.parse(this.responseText);
var urls = findAllImageUrls(data);
if (urls.size > 0) {
if (this._icUrl) recordApiCallPattern(this._icUrl);
var added = 0;
urls.forEach(function (url) {
if (addImageToState(url, 'api')) added++;
});
if (added > 0) {
console.log('[ImgCopy] XHR hook: +' + added + ' images');
renderSidebar();
}
}
}
} catch (e) { /* skip */ }
});
}
return origSend.apply(this, arguments);
};
console.log('[ImgCopy] API hooks installed (fetch + XHR)');
}
// ===========================================
// 5. AUTO-FETCH ENGINE
// ===========================================
// --- Strategy detection helpers ---
function findLoadMoreButton() {
// Exact text patterns for load-more buttons (short, focused)
var textPatterns = [/^(?:load|show|view|see)\s+(?:more|all)$/i, /^load\s+more\s+(?:results|items|content)$/i, /^show\s+more\s+(?:results|images|items|photos)$/i];
// Priority: look for structural/class-based selectors first (most reliable)
var structSelectors = [
'[class*="load-more"]', '[class*="loadMore"]',
'[class*="show-more"]', '[class*="showMore"]',
'[class*="pagination"]', '[class*="page-navi"]',
'[data-testid*="load"]', '[data-testid*="more"]',
'[id*="load-more"]', '[id*="loadMore"]',
'nav a[rel="next"]', 'nav button[rel="next"]'
];
for (var si = 0; si < structSelectors.length; si++) {
var found = document.querySelectorAll(structSelectors[si]);
for (var fi = 0; fi < found.length; fi++) {
var el = found[fi];
if (el.offsetParent === null) continue;
var t = el.textContent.trim();
if (t.length > 0 && t.length < 60 && textPatterns.some(function (p) { return p.test(t); })) return el;
}
}
// Fallback: scan visible buttons/anchors only — strict text match, exclude single-word "More"
var generic = document.querySelectorAll('button, a[role="button"], a');
for (var gi = 0; gi < generic.length; gi++) {
var g = generic[gi];
if (g.offsetParent === null) continue;
var txt = g.textContent.trim();
if (txt.length < 3 || txt.length > 60) continue;
if (txt === 'More' || txt === 'more') continue; // almost always a menu, not load-more
if (textPatterns.some(function (p) { return p.test(txt); })) return g;
}
// Only check aria-label if it explicitly says "load more" or "show more"
var ariaEls = document.querySelectorAll('[aria-label*="load more" i], [aria-label*="show more" i], [aria-label*="load additional" i]');
for (var ai = 0; ai < ariaEls.length; ai++) {
if (ariaEls[ai].offsetParent !== null) return ariaEls[ai];
}
return null;
}
function findNextPageLinkInDoc(doc) {
const d = doc || document;
let href = '';
const link = d.querySelector('link[rel="next"]');
if (link) href = link.getAttribute('href');
if (!href) {
const anchors = d.querySelectorAll('a');
const patterns = [/^next$/i, /^›$/, /^→$/, /^»$/, /^next\s*page/i, /^下一页/i];
for (const a of anchors) {
const text = a.textContent.trim();
if (patterns.some(p => p.test(text)) && a.href) { href = a.href; break; }
}
}
if (!href) {
const aria = d.querySelectorAll('a[aria-label*="next" i], a[rel="next"]');
for (const a of aria) { if (a.href) { href = a.href; break; } }
}
if (href && !href.startsWith('http')) {
try { href = new URL(href, location.origin).href; } catch { return null; }
}
return href || null;
}
function isPageScrollable() {
const sh = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
return sh > window.innerHeight + 50;
}
// --- Fetch strategies ---
async function tryLoadMore() {
var btn = findLoadMoreButton();
if (!btn) return 0;
updateFetchUI('running', 'Load More...');
btn.click();
await delay(1500);
return harvestImages();
}
async function tryScroll() {
if (!isPageScrollable()) return 0;
updateFetchUI('running', 'Scrolling...');
var before = document.body.scrollHeight;
window.scrollTo({ top: before, behavior: 'smooth' });
await delay(1200);
var added = harvestImages();
var grew = document.body.scrollHeight > before + 50;
return { added: added, grew: grew };
}
async function tryPagination() {
var nextUrl = state._paginationUrl || findNextPageLinkInDoc(document);
if (!nextUrl) return 0;
state._paginationUrl = null;
try {
updateFetchUI('running', 'Next page...');
var resp = await fetch(nextUrl, { headers: { 'Accept': 'text/html' } });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var html = await resp.text();
var doc = new DOMParser().parseFromString(html, 'text/html');
var added = harvestImages(doc);
state._paginationUrl = findNextPageLinkInDoc(doc);
return added;
} catch (e) {
console.warn('[ImgCopy] Pagination error:', e);
return 0;
}
}
async function fetchNextApiPage() {
if (_apiCallPatterns.length === 0) return 0;
var pattern = _apiCallPatterns[_apiCallPatterns.length - 1];
var nextValue = pattern.currentValue + 1;
var nextUrl = '';
try {
if (pattern.param === '_path_') {
nextUrl = pattern.baseUrl.replace(/:num/, nextValue);
} else {
var url = new URL(pattern.baseUrl);
url.searchParams.set(pattern.param, nextValue);
nextUrl = url.href;
}
} catch (e) { return 0; }
updateFetchUI('running', 'API page ' + nextValue + '...');
try {
var resp = await fetch(nextUrl, { credentials: 'same-origin' });
if (!resp.ok) return 0;
var ct = resp.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) return 0;
var data = await resp.json();
var urls = findAllImageUrls(data);
var added = 0;
urls.forEach(function (url) {
if (addImageToState(url, 'api')) added++;
});
if (added > 0) pattern.currentValue = nextValue;
return added;
} catch (e) {
console.warn('[ImgCopy] API page error:', e);
return 0;
}
}
// --- Main fetch control ---
async function startAutoFetch() {
if (state.isFetching) return;
var input = document.getElementById('ic-sidebar-target');
state.fetchTarget = (input ? parseInt(input.value, 10) : 0) || 0;
state.isFetching = true;
state.fetchStopped = false;
state._paginationUrl = null;
updateFetchUI('running');
harvestImages();
renderSidebar();
var emptyCycleCount = 0;
var maxEmpty = 3;
while (!state.fetchStopped) {
if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) {
updateFetchUI('done', 'Target reached: ' + state.fetchTarget);
break;
}
var actionTaken = false;
// --- Strategy 1: Load More ---
var loadBtn = findLoadMoreButton();
if (loadBtn && !state.fetchStopped) {
updateFetchUI('running', 'Load More...');
loadBtn.click();
await delay(1500);
var added = harvestImages();
if (added > 0) {
actionTaken = true;
renderSidebar();
if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
// Try load more again immediately
var nextBtn = findLoadMoreButton();
if (nextBtn && !state.fetchStopped) continue;
}
}
if (state.fetchStopped || (state.fetchTarget > 0 && state.images.size >= state.fetchTarget)) break;
// --- Strategy 2: Scroll ---
if (!actionTaken && isPageScrollable() && !state.fetchStopped) {
var scrollTries = 0;
while (scrollTries < 20 && !state.fetchStopped) {
updateFetchUI('running', 'Scrolling...');
var before = document.body.scrollHeight;
window.scrollTo({ top: before, behavior: 'smooth' });
await delay(1200);
var added2 = harvestImages();
if (added2 > 0) {
actionTaken = true;
renderSidebar();
if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
}
if (document.body.scrollHeight <= before + 50) break;
scrollTries++;
}
}
if (state.fetchStopped || (state.fetchTarget > 0 && state.images.size >= state.fetchTarget)) break;
// --- Strategy 3: DOM Pagination ---
if (!actionTaken && !state.fetchStopped) {
var pageUrl = state._paginationUrl || findNextPageLinkInDoc(document);
if (pageUrl) {
state._paginationUrl = null;
updateFetchUI('running', 'Next page...');
try {
var resp = await fetch(pageUrl, { headers: { 'Accept': 'text/html' } });
if (resp.ok) {
var doc = new DOMParser().parseFromString(await resp.text(), 'text/html');
var added3 = harvestImages(doc);
state._paginationUrl = findNextPageLinkInDoc(doc);
if (added3 > 0) {
actionTaken = true;
renderSidebar();
if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
// More pages?
if (state._paginationUrl && !state.fetchStopped) continue;
}
}
} catch (e) { console.warn('[ImgCopy] Pagination error:', e); }
}
}
if (state.fetchStopped || (state.fetchTarget > 0 && state.images.size >= state.fetchTarget)) break;
// --- Strategy 4: API Pattern (new - learns from intercepted API calls) ---
if (!actionTaken && _apiCallPatterns.length > 0 && !state.fetchStopped) {
var pageCount = 0;
while (_apiCallPatterns.length > 0 && !state.fetchStopped) {
var added4 = await fetchNextApiPage();
if (added4 > 0) {
actionTaken = true;
pageCount++;
renderSidebar();
if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
await delay(600);
} else {
// No more images from API, try next pattern
_apiCallPatterns.pop();
if (_apiCallPatterns.length > 0) continue;
break;
}
}
}
// --- Single-cycle: stop after one round ---
if (state.fetchTarget === 0) {
if (!actionTaken) updateFetchUI('done', 'Nothing more to load');
else updateFetchUI('done', 'One cycle complete');
break;
}
// --- Target mode: empty cycle handling ---
if (!actionTaken) {
emptyCycleCount++;
if (emptyCycleCount >= maxEmpty) {
updateFetchUI('done', 'No more images available');
break;
}
await delay(1000);
} else {
emptyCycleCount = 0;
}
}
state.isFetching = false;
if (!state.fetchStopped) updateFetchUI('done', 'Complete');
renderSidebar();
}
function stopAutoFetch() {
state.fetchStopped = true;
state.isFetching = false;
updateFetchUI('stopped');
}
function updateFetchUI(status, message) {
const btn = document.getElementById('ic-sidebar-fetch-btn');
const statusEl = document.getElementById('ic-sidebar-strategy');
if (!btn) return;
if (status === 'running') {
btn.innerHTML = '⏹ Stop';
btn.style.background = '#dc2626';
btn.dataset.running = 'true';
if (statusEl && message) statusEl.textContent = message;
} else if (status === 'done') {
btn.innerHTML = '▶ Start Fetch';
btn.style.background = '#059669';
btn.dataset.running = 'false';
if (statusEl) {
statusEl.textContent = '✅ ' + (message || 'Done');
setTimeout(() => { if (statusEl) statusEl.textContent = 'Strategy: Auto-detect'; }, 3000);
}
} else if (status === 'stopped') {
btn.innerHTML = '▶ Start Fetch';
btn.style.background = '#059669';
btn.dataset.running = 'false';
if (statusEl) statusEl.textContent = '⏸ Stopped';
}
}
// ===========================================
// 5. SIDEBAR UI
// ===========================================
function createSidebar() {
if (document.getElementById('ic-sidebar')) return;
const sb = document.createElement('div');
sb.id = 'ic-sidebar';
sb.style.cssText = [
'position:fixed;top:0;right:0;width:' + SB_WIDTH + 'px;height:100vh;',
'background:#f8f9fa;z-index:999999;box-shadow:-4px 0 20px rgba(0,0,0,0.15);',
'display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;',
'font-size:13px;color:#1f2937;transition:transform 0.25s ease;',
'transform:translateX(' + SB_WIDTH + 'px);'
].join('');
document.body.appendChild(sb);
// Header
const hdr = document.createElement('div');
hdr.style.cssText = 'padding:12px 16px;background:#059669;color:#fff;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;';
hdr.innerHTML = '<strong style="font-size:15px;">🖼️ Image Copy</strong><button id="ic-close-btn" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:2px 6px;">✕</button>';
sb.appendChild(hdr);
// Fetch controls
const fetchRow = document.createElement('div');
fetchRow.style.cssText = 'padding:10px 14px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;flex-direction:column;gap:8px;flex-shrink:0;';
fetchRow.innerHTML = [
'<div style="display:flex;gap:8px;align-items:center;">',
'<input type="number" id="ic-sidebar-target" placeholder="Target (0 = one cycle)" min="0"',
' style="flex:1;padding:7px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;outline:none;box-sizing:border-box;">',
'<button id="ic-sidebar-fetch-btn" style="background:#059669;color:#fff;border:none;padding:7px 16px;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;white-space:nowrap;">▶ Start Fetch</button>',
'</div>',
'<div id="ic-sidebar-strategy" style="font-size:11px;color:#6b7280;">Strategy: Auto-detect</div>'
].join('\n');
sb.appendChild(fetchRow);
// Filters
const flt = document.createElement('div');
flt.style.cssText = 'padding:10px 14px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;flex-direction:column;gap:6px;flex-shrink:0;';
flt.innerHTML = [
'<div style="display:flex;gap:6px;">',
'<input type="number" id="ic-filter-minw" placeholder="Min W" style="width:70px;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
'<input type="number" id="ic-filter-minh" placeholder="Min H" style="width:70px;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
'<input type="text" id="ic-filter-ext" placeholder="Ext (e.g. jpg,png)" style="flex:1;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
'</div>',
'<div style="display:flex;gap:6px;">',
'<input type="text" id="ic-filter-domain" placeholder="Domain (!prefix to exclude)" style="flex:1;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
'<input type="text" id="ic-filter-search" placeholder="🔍 Search file name..." style="flex:1;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
'</div>'
].join('\n');
sb.appendChild(flt);
// Action bar
const act = document.createElement('div');
act.style.cssText = 'padding:8px 14px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;';
act.innerHTML = [
'<label style="font-size:12px;cursor:pointer;display:flex;align-items:center;gap:5px;color:#374151;">',
'<input type="checkbox" id="ic-select-all" style="cursor:pointer;width:15px;height:15px;accent-color:#059669;"> Select All',
'</label>',
'<div style="display:flex;gap:6px;">',
'<button id="ic-clear-btn" style="background:#6b7280;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-weight:600;font-size:11px;">🗑️ Clear</button>',
'<button id="ic-copy-btn" style="background:#059669;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;">📋 Copy URLs</button>',
'</div>'
].join('\n');
sb.appendChild(act);
// Stats
const st = document.createElement('div');
st.id = 'ic-stats';
st.style.cssText = 'padding:4px 14px;background:#f3f4f6;font-size:11px;color:#6b7280;display:flex;justify-content:space-between;flex-shrink:0;border-bottom:1px solid #e5e7eb;';
st.innerHTML = '<span>No images</span><span>0 selected</span>';
sb.appendChild(st);
// Image list
const list = document.createElement('div');
list.id = 'ic-list';
list.style.cssText = 'flex:1;overflow-y:auto;padding:8px 10px;background:#f0f2f2;';
sb.appendChild(list);
// --- Bind events ---
document.getElementById('ic-close-btn').onclick = hideSidebar;
document.getElementById('ic-sidebar-fetch-btn').addEventListener('click', function () {
if (this.dataset.running === 'true') stopAutoFetch();
else startAutoFetch();
});
['ic-filter-minw', 'ic-filter-minh', 'ic-filter-ext', 'ic-filter-domain', 'ic-filter-search'].forEach(function (id) {
document.getElementById(id).addEventListener('input', renderSidebar);
});
document.getElementById('ic-select-all').addEventListener('change', function () {
const checked = this.checked;
getFilteredImages().forEach(function (img) { img.selected = checked; });
renderSidebar();
});
document.getElementById('ic-copy-btn').addEventListener('click', copySelectedUrls);
document.getElementById('ic-clear-btn').addEventListener('click', clearAllImages);
renderSidebar();
}
function showSidebar() {
const sb = document.getElementById('ic-sidebar');
if (!sb) return;
state.sidebarVisible = true;
sb.style.transform = 'translateX(0)';
const toggle = document.getElementById('ic-toggle-btn');
if (toggle) toggle.style.display = 'none';
}
function hideSidebar() {
const sb = document.getElementById('ic-sidebar');
if (!sb) return;
state.sidebarVisible = false;
sb.style.transform = 'translateX(' + SB_WIDTH + 'px)';
const toggle = document.getElementById('ic-toggle-btn');
if (toggle) toggle.style.display = 'flex';
}
function createBottomToggle() {
if (document.getElementById('ic-toggle-btn')) return;
const btn = document.createElement('button');
btn.id = 'ic-toggle-btn';
btn.innerHTML = '🖼️';
btn.title = 'Open Image Copy Sidebar';
btn.style.cssText = [
'position:fixed;bottom:20px;right:20px;',
'width:48px;height:48px;border-radius:50%;',
'background:#059669;color:#fff;border:none;',
'cursor:pointer;z-index:999998;font-size:20px;',
'box-shadow:0 4px 12px rgba(0,0,0,0.25);',
'display:flex;align-items:center;justify-content:center;',
'transition:transform 0.2s, opacity 0.2s;',
'opacity:0.9;'
].join('');
btn.onmouseenter = function () { this.style.transform = 'scale(1.1)'; this.style.opacity = '1'; };
btn.onmouseleave = function () { this.style.transform = 'scale(1)'; this.style.opacity = '0.9'; };
btn.onclick = function () {
if (!document.getElementById('ic-sidebar')) createSidebar();
showSidebar();
setTimeout(function () {
const fetchBtn = document.getElementById('ic-sidebar-fetch-btn');
if (fetchBtn && fetchBtn.dataset.running !== 'true') startAutoFetch();
}, 300);
};
document.body.appendChild(btn);
}
// ===========================================
// 6. FILTERING & RENDERING
// ===========================================
function getFilteredImages() {
let arr = Array.from(state.images.values());
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
arr = arr.filter(function (img) {
return getFilenameFromUrl(img.url).toLowerCase().includes(q) || img.alt.toLowerCase().includes(q);
});
}
if (state.minWidth > 0) arr = arr.filter(function (img) { return img.width >= state.minWidth || img.width === 0; });
if (state.minHeight > 0) arr = arr.filter(function (img) { return img.height >= state.minHeight || img.height === 0; });
if (state.extFilter) {
const exts = state.extFilter.split(',').map(function (s) { return s.trim().toLowerCase().replace(/^\./, ''); });
if (exts.length > 0 && exts[0] !== '') {
arr = arr.filter(function (img) {
const ext = img.url.split('?')[0].split('.').pop().toLowerCase();
return exts.indexOf(ext) !== -1;
});
}
}
if (state.domainFilter) {
const parts = state.domainFilter.split(',');
for (let pi = 0; pi < parts.length; pi++) {
const p = parts[pi].trim();
if (!p) continue;
const exclude = p.charAt(0) === '!';
const domain = exclude ? p.slice(1).trim() : p;
if (!domain) continue;
arr = arr.filter(function (img) {
try {
const host = new URL(img.url).hostname;
const match = host.indexOf(domain) !== -1;
return exclude ? !match : match;
} catch { return true; }
});
}
}
arr.sort(function (a, b) { return a.timestamp - b.timestamp; });
return arr;
}
function renderSidebar() {
const list = document.getElementById('ic-list');
if (!list) return;
syncFilterState();
const visible = getFilteredImages();
const total = state.images.size;
const selCount = 0;
let selected = 0;
state.images.forEach(function (img) { if (img.selected) selected++; });
const statsEl = document.getElementById('ic-stats');
if (statsEl) {
statsEl.innerHTML = '<span>📸 <b>' + visible.length + '</b> / ' + total + ' images</span><span>✅ <b>' + selected + '</b> selected</span>';
}
const scrollPos = list.scrollTop;
list.innerHTML = '';
if (visible.length === 0) {
list.innerHTML = '<div style="text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px;">' +
(total === 0
? 'No images found yet.<br>Click 🖼️ (bottom-right)<br>to open & start fetching.'
: 'No images match your filters.') +
'</div>';
return;
}
for (let vi = 0; vi < visible.length; vi++) {
const img = visible[vi];
const item = document.createElement('div');
const isSel = img.selected;
var srcBadge = '';
if (img.source === 'bg') srcBadge = '<span style="background:#dbeafe;color:#1e40af;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px;">bg</span>';
else if (img.source === 'api') srcBadge = '<span style="background:#fef3c7;color:#92400e;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px;">api</span>';
item.style.cssText = 'display:flex;gap:10px;padding:8px 10px;margin-bottom:6px;background:' +
(isSel ? '#f0fdf4' : '#fff') + ';border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.08);' +
'cursor:pointer;align-items:center;border-left:3px solid ' + (isSel ? '#059669' : '#d1d5db') + ';';
item.onclick = function (pin) {
return function (e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'A') return;
pin.selected = !pin.selected;
renderSidebar();
};
}(img);
const filename = getFilenameFromUrl(img.url);
const dims = dim(img.width) + '×' + dim(img.height) + 'px';
item.innerHTML = [
'<input type="checkbox" ' + (isSel ? 'checked' : '') + ' style="width:16px;height:16px;cursor:pointer;accent-color:#059669;flex-shrink:0;">',
'<img src="' + escape(img.url) + '" style="width:50px;height:50px;border-radius:6px;object-fit:cover;background:#e5e7eb;flex-shrink:0;" loading="lazy" onerror="this.style.display=\'none\'">',
'<div style="flex:1;min-width:0;overflow:hidden;">',
'<div style="font-size:11px;font-weight:600;color:#374151;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escape(filename) + '">' + escape(filename) + srcBadge + '</div>',
'<div style="font-size:10px;color:#9ca3af;margin-top:1px;">' + dims + '</div>',
'<div style="font-size:9px;color:#6b7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px;" title="' + escape(img.url) + '">' + escape(img.url) + '</div>',
'</div>'
].join('');
const cb = item.querySelector('input[type="checkbox"]');
cb.onchange = function (pin) {
return function (e) { pin.selected = e.target.checked; renderSidebar(); };
}(img);
list.appendChild(item);
}
list.scrollTop = scrollPos;
const selAll = document.getElementById('ic-select-all');
if (visible.length > 0) {
selAll.checked = visible.every(function (i) { return i.selected; });
selAll.indeterminate = visible.some(function (i) { return i.selected; }) && !visible.every(function (i) { return i.selected; });
} else {
selAll.checked = false;
selAll.indeterminate = false;
}
}
function syncFilterState() {
state.searchQuery = document.getElementById('ic-filter-search') ? document.getElementById('ic-filter-search').value : '';
state.minWidth = parseInt(document.getElementById('ic-filter-minw') ? document.getElementById('ic-filter-minw').value : '', 10) || 0;
state.minHeight = parseInt(document.getElementById('ic-filter-minh') ? document.getElementById('ic-filter-minh').value : '', 10) || 0;
state.extFilter = document.getElementById('ic-filter-ext') ? document.getElementById('ic-filter-ext').value : '';
state.domainFilter = document.getElementById('ic-filter-domain') ? document.getElementById('ic-filter-domain').value : '';
}
// ===========================================
// 7. COPY
// ===========================================
function copySelectedUrls() {
const selected = [];
state.images.forEach(function (img) { if (img.selected) selected.push(img); });
const urls = selected.length > 0 ? selected.map(function (i) { return i.url; }) : getFilteredImages().map(function (i) { return i.url; });
if (urls.length === 0) {
const btn = document.getElementById('ic-copy-btn');
const orig = btn.innerHTML;
btn.innerHTML = '⚠️ Nothing to copy';
btn.style.background = '#dc2626';
setTimeout(function () { btn.innerHTML = orig; btn.style.background = '#059669'; }, 2000);
return;
}
navigator.clipboard.writeText(urls.join('\n')).then(function () {
const btn = document.getElementById('ic-copy-btn');
const orig = btn.innerHTML;
btn.innerHTML = '✅ ' + urls.length + ' URLs copied';
btn.style.background = '#137333';
setTimeout(function () { btn.innerHTML = orig; btn.style.background = '#059669'; }, 2500);
}).catch(function (err) {
console.error('[ImgCopy] Copy failed:', err);
alert('Copy failed: ' + err.message);
});
}
function clearAllImages() {
if (state.images.size === 0) return;
state.images.clear();
state.seenSet.clear();
state._paginationUrl = null;
renderSidebar();
var btn = document.getElementById('ic-clear-btn');
var orig = btn.innerHTML;
btn.innerHTML = '✅ Cleared';
btn.style.background = '#dc2626';
setTimeout(function () { btn.innerHTML = orig; btn.style.background = '#6b7280'; }, 1500);
}
// ===========================================
// 8. KEYBOARD SHORTCUTS
// ===========================================
// Track mouse for peek-through
document.addEventListener('mousemove', function (e) {
state._mouseX = e.clientX;
state._mouseY = e.clientY;
});
document.addEventListener('keydown', function (e) {
// Opt+C -> Peek-through copy image under cursor
if ((e.altKey || e.metaKey) && e.key.toLowerCase() === 'c') {
const result = findImageAtPoint(state._mouseX, state._mouseY);
if (result.image) {
let url = result.image.getAttribute('content') || result.image.getAttribute('src');
if (!url) {
const srcset = result.image.getAttribute('srcset');
if (srcset) url = srcset.split(',')[0].trim().split(' ')[0];
}
if (url) {
navigator.clipboard.writeText(url)
.then(function () { console.log('[ImgCopy] Copied:', url); })
.catch(function (err) { console.error('[ImgCopy] Clipboard error:', err); });
}
}
result.overlays.forEach(function (o) { o.element.style.pointerEvents = o.original; });
}
});
function findImageAtPoint(x, y, maxIter) {
maxIter = maxIter || 10;
let iter = 0;
let image = null;
const overlays = [];
while (iter < maxIter) {
const el = document.elementFromPoint(x, y);
if (!el) break;
if (el.tagName === 'IMG') { image = el; break; }
const desc = el.querySelector('img');
if (desc) { image = desc; break; }
const orig = el.style.pointerEvents;
overlays.push({ element: el, original: orig });
el.style.pointerEvents = 'none';
iter++;
}
return { image: image, overlays: overlays };
}
// ===========================================
// 9. MUTATION OBSERVER
// ===========================================
let harvestTimer = null;
function scheduleHarvest() {
if (harvestTimer) clearTimeout(harvestTimer);
harvestTimer = setTimeout(function () {
const added = harvestImages();
if (added > 0) renderSidebar();
}, 600);
}
function startObserver() {
try {
const obs = new MutationObserver(function () { scheduleHarvest(); });
obs.observe(document.body, { childList: true, subtree: true, attributes: false });
} catch (e) {
console.warn('[ImgCopy] Observer error:', e);
}
}
// ===========================================
// 10. URL CHANGE DETECTION
// ===========================================
setInterval(function () {
if (location.href !== state.lastUrl) {
state.lastUrl = location.href;
state.images.clear();
state.seenSet.clear();
state._paginationUrl = null;
setTimeout(function () {
harvestImages();
if (document.getElementById('ic-sidebar')) renderSidebar();
}, 800);
}
}, 1000);
// ===========================================
// 11. INIT
// ===========================================
function init() {
if (window.__icInit) return;
window.__icInit = true;
setupAPIHooks();
var ci = setInterval(function () {
if (document.body) {
clearInterval(ci);
createBottomToggle();
startObserver();
harvestImages();
}
}, 300);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();