Adds a "View Image" button to Image Search results on [Bing/DuckDuckGo/Google].
// ==UserScript==
// @name Image-Search-Direct-View
// @namespace https://github.com/p65536
// @version 1.3.0
// @license MIT
// @description Adds a "View Image" button to Image Search results on [Bing/DuckDuckGo/Google].
// @icon data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24px' viewBox='0 -960 960 960' width='24px' fill='%235985E1'%3E%3Cpath d='M240-280h480L597-444q-11-2-22.5-5t-22.5-7L450-320l-90-120-120 160Zm-40 160q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h200v80H200v560h560v-213l80 80v133q0 33-23.5 56.5T760-120H200Zm280-360Zm382 56L738-548q-21 14-45 21t-51 7q-74 0-126-52.5T464-700q0-75 52.5-127.5T644-880q75 0 127.5 52.5T824-700q0 27-8 52t-20 46l122 122-56 56ZM644-600q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Z'/%3E%3C/svg%3E
// @author p65536
// @match https://*.bing.com/images/search*
// @match https://*.bing.com/images/vnext/search*
// @match https://duckduckgo.com/*
// @match https://*.google.com/search*
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @grant GM.xmlHttpRequest
// @grant GM.openInTab
// @connect *
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
// =================================================================================
// SECTION: Platform-Specific Definitions
// =================================================================================
const OWNERID = 'p65536';
const APPID = 'isdv';
const APPNAME = 'Image Search Direct View';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
// =================================================================================
// SECTION: Configuration Definitions
// =================================================================================
const CONSTANTS = {
CONFIG_KEY: `${APPID}_config`,
TOAST_DURATION: 3000,
TOAST_FADE_OUT_DURATION: 300,
NETWORK_TIMEOUT: 20000, // 20 seconds
WAIT_FOR_VALID_URL_TIMEOUT: 500,
MODAL: {
WIDTH: 400,
Z_INDEX: 20000,
},
REFERRER_POLICY: {
NO_REFERRER: 'no-referrer',
ORIGIN: 'origin',
UNSAFE_URL: 'unsafe-url',
},
FETCH_STRATEGY: {
AUTO: 'auto',
BLOB: 'blob',
DIRECT: 'direct',
},
TIMEOUTS: {
FETCH_ORIGINAL: 3000,
DOM_POLLING: 100,
SCROLL_CLAMP: 500,
UI_DELAY: 100,
POST_NAVIGATION_DOM_SETTLE: 500,
},
ICONS: {
IMAGE: {
tag: 'svg',
props: { viewBox: '0 0 24 24', width: '18px', height: '18px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z' } }],
},
GLOBE: {
tag: 'svg',
props: { viewBox: '0 0 24 24', width: '18px', height: '18px', fill: 'currentColor' },
children: [
{
tag: 'path',
props: {
d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z',
},
},
],
},
},
LOG_TAGS: {
ORIGINAL: 'ORIGINAL',
THUMBNAIL: 'THUMBNAIL',
},
NAV_PURPOSE: {
MAIN: 'main',
},
RESOURCE_KEYS: {
ADAPTER: 'adapter',
SETTINGS_MODAL: 'settingsModal',
ACTIVE_FETCH: 'activeFetch',
},
PROCESSED_CLASS: `${APPID}-processed`,
};
const SITE_STYLES = {
google: {
// For Modal & Common Settings (Dark mode support via CSS vars)
bg: 'var(--background-color, Canvas)',
text: 'var(--primary-text-color, CanvasText)',
border: 'var(--border-color, ButtonBorder)',
header_bg: 'var(--header-bg-color, var(--background-color, Canvas))',
btn_bg: 'var(--background-color, Canvas)',
btn_text: 'var(--primary-text-color, CanvasText)',
btn_border: 'var(--border-color, ButtonBorder)',
// Adjusted fallback to be visible in both Light (darken) and Dark (lighten via var) modes
btn_hover_bg: 'var(--hover-bg-color, rgb(0 0 0 / 0.08))',
input_bg: 'var(--textfield-surface, Field)',
input_text: 'var(--textfield-primary, FieldText)',
input_border: 'var(--border-color, FieldBorder)',
accent: 'rgb(66 133 244)', // Google Blue
text_secondary: 'var(--secondary-text-color, GrayText)',
// Variable mapping specific to Button UI
vars: {
[`--${APPID}-bg`]: 'var(--background-color, Canvas)',
[`--${APPID}-text`]: 'var(--primary-text-color, CanvasText)',
[`--${APPID}-border`]: 'var(--border-color, ButtonBorder)',
// Hover bg: Solid color (Google Light Gray) instead of transparent
[`--${APPID}-hover-bg`]: 'var(--hover-bg-color, rgb(241 243 244))',
[`--${APPID}-accent`]: 'rgb(66 133 244)',
// Alert Colors (Light Mode Default)
[`--${APPID}-unsafe`]: 'rgb(217 48 37)',
[`--${APPID}-noref`]: 'rgb(26 13 171)', // Google Link Blue
},
// Override settings for Dark Mode (Supplementing incomplete CSS vars)
css_overrides: `
@media (prefers-color-scheme: dark) {
:root {
/* Hover bg: Solid color (Google Dark Gray) */
--${APPID}-hover-bg: rgb(48 49 52);
--${APPID}-unsafe: rgb(224 96 85); /* Bright red for dark mode */
--${APPID}-noref: rgb(138 180 248); /* Bright blue for dark mode */
}
}
`,
overrides: '', // No layout override needed for Google
},
bing: {
// Bing Colors (Use as is, since CSS vars are complete)
bg: 'var(--c-w-1, Canvas)',
text: 'var(--c-t-1, CanvasText)',
border: 'var(--c-s-1, ButtonBorder)',
header_bg: 'var(--c-w-1, Canvas)',
btn_bg: 'var(--c-w-1, ButtonFace)',
btn_text: 'var(--c-t-1, ButtonText)',
btn_border: 'var(--c-s-1, ButtonBorder)',
btn_hover_bg: 'var(--c-s-2, Highlight)',
input_bg: 'var(--c-w-1, Field)',
input_text: 'var(--c-t-1, FieldText)',
input_border: 'var(--c-s-1, FieldBorder)',
accent: 'rgb(0 120 212)', // Bing Blue
text_secondary: 'var(--c-t-2, GrayText)',
// Variable mapping specific to Button UI
vars: {
[`--${APPID}-bg`]: 'var(--c-w-1)',
[`--${APPID}-text`]: 'var(--c-t-1)',
[`--${APPID}-border`]: 'var(--c-s-1)',
[`--${APPID}-hover-bg`]: 'var(--c-s-2)',
[`--${APPID}-accent`]: 'rgb(0 120 212)',
[`--${APPID}-unsafe`]: 'rgb(217 48 37)',
[`--${APPID}-noref`]: 'var(--c-h-1)', // Bing link color
},
// No override needed as Bing variables switch automatically
css_overrides: '',
// Ensure buttons are visible (z-index: 1)
overrides: `
.${APPID}-icon-btn {
left: 8px;
right: auto;
z-index: 1 !important;
}
.${APPID}-icon-btn:hover {
z-index: 2 !important;
}
`,
},
duckduckgo: {
// DuckDuckGo Colors (Using their native CSS variables)
bg: 'var(--color-bg-main, rgb(255 255 255))',
text: 'var(--color-text-primary, rgb(51 51 51))',
border: 'var(--color-border-main, rgb(204 204 204))',
header_bg: 'var(--color-bg-main, rgb(255 255 255))',
btn_bg: 'var(--color-bg-main, rgb(255 255 255))',
btn_text: 'var(--color-text-primary, rgb(51 51 51))',
btn_border: 'var(--color-border-main, rgb(204 204 204))',
btn_hover_bg: 'var(--color-bg-dim, rgb(240 240 240))',
input_bg: 'var(--color-bg-input, rgb(255 255 255))',
input_text: 'var(--color-text-primary, rgb(51 51 51))',
input_border: 'var(--color-border-main, rgb(204 204 204))',
accent: 'rgb(222 88 51)', // DDG Orange/Red
text_secondary: 'var(--color-text-secondary, rgb(102 102 102))',
// Variable mapping specific to Button UI
vars: {
[`--${APPID}-bg`]: 'var(--color-bg-main, rgb(255 255 255))',
[`--${APPID}-text`]: 'var(--color-text-primary, rgb(51 51 51))',
[`--${APPID}-border`]: 'var(--color-border-main, rgb(0 0 0 / 0.1))',
[`--${APPID}-hover-bg`]: 'var(--color-bg-dim, rgb(240 240 240))',
[`--${APPID}-accent`]: 'rgb(222 88 51)',
[`--${APPID}-unsafe`]: 'rgb(217 48 37)',
[`--${APPID}-noref`]: 'rgb(64 150 255)',
},
css_overrides: '',
// Position: Default (Top-Right)
// 1. Set ISDV button z-index to 1 to ensure it sits above the image.
// 2. Force DDG's menu button to z-index 100 to ensure it sits above ISDV button.
overrides: `
.${APPID}-icon-btn {
z-index: 1 !important;
}
.${APPID}-icon-btn:hover {
z-index: 2 !important;
}
/* Force DDG menu button above ISDV buttons */
figure button[aria-label="menu"] {
z-index: 100 !important;
}
`,
},
};
const DEFAULT_CONFIG = {
common: {
showOnlyOnHover: false, // Default to false so users see buttons immediately
showVisitPageButton: true, // Show the "Visit Page" button by default
referrerPolicy: CONSTANTS.REFERRER_POLICY.ORIGIN, // Default: Send origin only
retryOnFailure: false, // Default: Do not retry automatically
fetchStrategy: CONSTANTS.FETCH_STRATEGY.AUTO, // Default: Auto Detect
blobRevokeTimeout: 600000, // Default: 10 minutes (600,000 ms)
},
developer: {
logger_level: 'log',
},
};
const EVENTS = {
CONFIG_UPDATED: `${APPID}:configUpdated`,
NAVIGATION: `${APPID}:navigation`,
};
const UI_STYLES = {
BASE: `
/* Button Style (Icon Button) - CSS Variable Support */
.${APPID}-icon-btn {
position: absolute;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--${APPID}-bg, rgb(255 255 255));
border: 1px solid var(--${APPID}-border, rgb(0 0 0 / 0.1));
/* Increased shadow opacity for better visibility */
box-shadow: 0 2px 5px rgb(0 0 0 / 0.3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--${APPID}-text, rgb(95 99 104));
transition: transform 0.1s, background-color 0.1s, opacity 0.2s ease-in-out;
z-index: 10;
}
.${APPID}-icon-btn:hover {
background-color: var(--${APPID}-hover-bg, rgb(241 243 244));
color: var(--${APPID}-text, rgb(32 33 36));
transform: scale(1.1);
box-shadow: 0 4px 10px rgb(0 0 0 / 0.4);
z-index: 11;
}
.${APPID}-icon-btn:active {
transform: scale(0.95);
}
/* Policy Colors - Controlled via CSS Variables */
/* Origin (Default) - Use standard text color (Native look) */
body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.ORIGIN}"] .${APPID}-icon-btn { color: var(--${APPID}-text) !important; }
body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.ORIGIN}"] .${APPID}-icon-btn:hover { color: var(--${APPID}-text) !important; }
/* Unsafe URL - Red (Alert) */
body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.UNSAFE_URL}"] .${APPID}-icon-btn { color: var(--${APPID}-unsafe) !important; }
body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.UNSAFE_URL}"] .${APPID}-icon-btn:hover { color: var(--${APPID}-unsafe) !important; }
/* No Referrer - Blue (Custom/Link Color) */
body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.NO_REFERRER}"] .${APPID}-icon-btn { color: var(--${APPID}-noref) !important; }
body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.NO_REFERRER}"] .${APPID}-icon-btn:hover { color: var(--${APPID}-noref) !important; }
/* Container Style */
.${APPID}-container {
position: relative;
}
/* Default Positions */
.${APPID}-btn-view-image {
top: 8px;
}
.${APPID}-btn-visit-page {
top: 48px; /* 8px (top) + 32px (btn) + 8px (gap) */
}
/* Toast */
.${APPID}-toast-container {
position: fixed; top: 80px; left: 50%; transform: translateX(-50%);
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px;
}
.${APPID}-toast {
padding: 10px 20px; border-radius: 24px; color: white;
font-family: Roboto, Arial, sans-serif; font-size: 14px;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.3);
animation: ${APPID}-fade-in 0.3s ease-out;
}
.${APPID}-toast-info { background-color: rgb(51 51 51); }
.${APPID}-toast-warn { background-color: rgb(251 188 4); color: rgb(32 33 36); }
.${APPID}-toast-error { background-color: rgb(217 48 37); }
@keyframes ${APPID}-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
`,
HOVER_ENABLE: `
.${APPID}-icon-btn {
opacity: 0;
pointer-events: none;
}
.${APPID}-container:hover .${APPID}-icon-btn {
opacity: 1;
pointer-events: auto;
}
`,
HOVER_DISABLE: `
.${APPID}-icon-btn {
opacity: 1;
pointer-events: auto;
}
`,
};
// =================================================================================
// SECTION: Logging Utility
// Description: Centralized logging interface for consistent log output across modules.
// Handles log level control, message formatting, and console API wrapping.
// =================================================================================
class Logger {
/** @property {object} levels - Defines the numerical hierarchy of log levels. */
static levels = {
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
};
/** @property {string} level - The current active log level. */
static level = 'log'; // Default level
/**
* Defines the available badge styles.
* @property {object} styles
*/
static styles = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
RED: 'background: rgb(220 53 69);',
YELLOW: 'background: rgb(255 193 7); color: black;',
GREEN: 'background: rgb(40 167 69);',
BLUE: 'background: rgb(0 123 255);',
GRAY: 'background: rgb(108 117 125);',
ORANGE: 'background: rgb(253 126 20);',
PINK: 'background: rgb(232 62 140);',
PURPLE: 'background: rgb(111 66 193);',
CYAN: 'background: rgb(23 162 184); color: black;',
TEAL: 'background: rgb(32 201 151); color: black;',
};
/**
* Maps log levels to default badge styles.
* @private
*/
static _defaultStyles = {
error: this.styles.RED,
warn: this.styles.YELLOW,
info: this.styles.BLUE,
log: this.styles.GREEN,
debug: this.styles.GRAY,
};
/**
* Sets the current log level.
* @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'.
*/
static setLevel(level) {
if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
this.level = level;
} else {
// Use default style (empty string) for the badge
this._out('warn', 'INVALID LEVEL', '', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/**
* Internal method to output logs if the level permits.
* @private
* @param {string} level - The log level ('error', 'warn', 'info', 'log', 'debug').
* @param {string} badgeText - The text inside the badge. If empty, no badge is shown.
* @param {string} badgeStyle - The background-color style (from Logger.styles). If empty, uses default.
* @param {...any} args - The messages to log.
*/
static _out(level, badgeText, badgeStyle, ...args) {
if (this.levels[this.level] >= this.levels[level]) {
const consoleMethod = console[level] || console.log;
if (badgeText !== '') {
// Badge mode: Use %c formatting
let style = badgeStyle;
if (style === '') {
style = this._defaultStyles[level] || this.styles.GRAY;
}
const combinedStyle = `${this.styles.BASE} ${style}`;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
combinedStyle, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
} else {
// No badge mode: Direct output for better object inspection
consoleMethod(LOG_PREFIX, ...args);
}
}
}
/**
* Internal method to start a log group if the level permits (debug or higher).
* @private
* @param {'group'|'groupCollapsed'} method - The console method to use.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static _groupOut(method, badgeText, badgeStyle, ...args) {
if (this.levels[this.level] >= this.levels.debug) {
const consoleMethod = console[method];
if (badgeText !== '') {
let style = badgeStyle;
if (style === '') {
style = this.styles.GRAY;
}
const combinedStyle = `${this.styles.BASE} ${style}`;
consoleMethod(`%c${LOG_PREFIX}%c %c${badgeText}%c`, 'font-weight: bold;', 'color: inherit;', combinedStyle, 'color: inherit;', ...args);
} else {
consoleMethod(LOG_PREFIX, ...args);
}
}
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static error(badgeText, badgeStyle, ...args) {
this._out('error', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static warn(badgeText, badgeStyle, ...args) {
this._out('warn', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static info(badgeText, badgeStyle, ...args) {
this._out('info', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static log(badgeText, badgeStyle, ...args) {
this._out('log', badgeText, badgeStyle, ...args);
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static debug(badgeText, badgeStyle, ...args) {
this._out('debug', badgeText, badgeStyle, ...args);
}
/**
* Starts a timer for performance measurement. Only active in 'debug' level.
* @param {string} label The label for the timer.
*/
static time(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.time(`${LOG_PREFIX} ${label}`);
}
}
/**
* Ends a timer and logs the elapsed time. Only active in 'debug' level.
* @param {string} label The label for the timer, must match the one used in time().
*/
static timeEnd(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.timeEnd(`${LOG_PREFIX} ${label}`);
}
}
/**
* Starts a log group. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args The title for the log group.
*/
static group(badgeText, badgeStyle, ...args) {
this._groupOut('group', badgeText, badgeStyle, ...args);
}
/**
* Starts a collapsed log group. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args The title for the log group.
*/
static groupCollapsed(badgeText, badgeStyle, ...args) {
this._groupOut('groupCollapsed', badgeText, badgeStyle, ...args);
}
/**
* Closes the current log group. Only active in 'debug' level.
* @returns {void}
*/
static groupEnd() {
if (this.levels[this.level] >= this.levels.debug) {
console.groupEnd();
}
}
}
// Alias for ease of use
const LOG_STYLES = Logger.styles;
// =================================================================================
// SECTION: Execution Guard
// Description: Prevents the script from being executed multiple times per page.
// =================================================================================
class ExecutionGuard {
// A shared key for all scripts from the same author to avoid polluting the window object.
static #GUARD_KEY = `__${OWNERID}_guard__`;
// A specific key for this particular script.
static #APP_KEY = `${APPID}_executed`;
/**
* Checks if the script has already been executed on the page.
* @returns {boolean} True if the script has run, otherwise false.
*/
static hasExecuted() {
return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
}
/**
* Sets the flag indicating the script has now been executed.
*/
static setExecuted() {
window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
window[this.#GUARD_KEY][this.#APP_KEY] = true;
}
}
// =================================================================================
// SECTION: General Utilities
// =================================================================================
/**
* Schedules a function to run when the browser is idle.
* Returns a cancel function to abort the scheduled task.
* In environments without `requestIdleCallback`, this runs asynchronously immediately (1ms delay) to prevent blocking,
* effectively ignoring the `timeout` constraint by satisfying it instantly.
* @param {(deadline: IdleDeadline) => void} callback The function to execute.
* @param {number} timeout The maximum time to wait for idle before forcing execution.
* @returns {() => void} A function to cancel the scheduled task.
*/
function runWhenIdle(callback, timeout) {
const FALLBACK_DELAY_MS = 1;
const SIMULATED_TIME_REMAINING_MS = 50;
if ('requestIdleCallback' in window) {
const id = window.requestIdleCallback(callback, { timeout });
return () => window.cancelIdleCallback(id);
} else {
// Fallback: Execute almost immediately to avoid blocking.
// This satisfies the "run by timeout" contract trivially.
const id = setTimeout(() => {
// [DO NOT REFACTOR] Duck Typing for API Compatibility
// Provide a minimal IdleDeadline-like object.
// Do not simplify or remove this object structure, as callers expect the `timeRemaining` method.
callback({
didTimeout: false,
timeRemaining: () => SIMULATED_TIME_REMAINING_MS,
});
}, FALLBACK_DELAY_MS);
return () => clearTimeout(id);
}
}
/**
* @param {Function} func
* @param {number} delay
* @param {boolean} useIdle
* @returns {((...args: unknown[]) => void) & { cancel: () => void }}
*/
function debounce(func, delay, useIdle) {
let timerId = null;
let cancelIdle = null;
const cancel = () => {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
if (cancelIdle) {
cancelIdle();
cancelIdle = null;
}
};
// [DO NOT REFACTOR] Must remain a standard function, not an arrow function.
// This ensures the dynamic `this` context from the caller is correctly captured
// and propagated to the target function via `func.apply(this, args)`.
/** @this {any} */
const debounced = function (...args) {
cancel();
timerId = setTimeout(() => {
timerId = null; // Timer finished
if (useIdle) {
// Calculate idle timeout based on delay: clamp(delay * 4, 200, 2000)
// This ensures short delays don't wait too long, while long delays are capped.
const idleTimeout = Math.min(Math.max(delay * 4, 200), 2000);
// Schedule idle callback and store the cancel function
// Explicitly receive 'deadline' to match runWhenIdle signature
cancelIdle = runWhenIdle((deadline) => {
cancelIdle = null; // Idle callback finished
func.apply(this, args);
}, idleTimeout);
} else {
func.apply(this, args);
}
}, delay);
};
debounced.cancel = cancel;
return debounced;
}
/**
* Helper function to check if an item is a non-array object.
* @param {unknown} item The item to check.
* @returns {item is Record<string, any>}
*/
function isObject(item) {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Creates a deep copy of a JSON-serializable object.
* @template T
* @param {T} obj The object to clone.
* @returns {T} The deep copy of the object.
*/
function deepClone(obj) {
try {
return structuredClone(obj);
} catch (e) {
Logger.error('CLONE FAILED', '', 'deepClone failed. Data contains non-clonable items.', e);
throw e;
}
}
/**
* Recursively resolves the configuration by overlaying source properties onto the target object.
* The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives.
*
* [MERGE BEHAVIOR]
* Keys present in 'source' but missing in 'target' are ignored.
* The 'target' object acts as a schema; it must contain all valid keys.
*
* @param {object} target The target object (e.g., a deep copy of default config).
* @param {object} source The source object (e.g., user config).
* @returns {object} The mutated target object.
*/
function resolveConfig(target, source) {
for (const [key, sourceVal] of Object.entries(source)) {
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
// Strict check: Ignore keys that do not exist in the target (default config).
if (!Object.hasOwn(target, key)) {
continue;
}
const targetVal = target[key];
if (isObject(sourceVal) && isObject(targetVal)) {
// If both are objects, recurse
resolveConfig(targetVal, sourceVal);
} else if (typeof sourceVal !== 'undefined') {
// Otherwise, overwrite or set the value from the source
target[key] = sourceVal;
}
}
return target;
}
/**
* @typedef {Node|string|number|boolean|null|undefined} HChild
*/
/**
* Creates a DOM element using a hyperscript-style syntax.
* @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
* @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
* @param {HChild | HChild[]} [children] - Children (if props are specified).
* @returns {HTMLElement | SVGElement} The created DOM element.
*/
function h(tag, propsOrChildren, children) {
const SVG_NS = 'http://www.w3.org/2000/svg';
const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
if (!match) throw new Error(`Invalid tag syntax: ${tag}`);
const [, tagName, id, classList] = match;
const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);
if (id) el.id = id.slice(1);
if (classList) {
const classes = classList.replaceAll('.', ' ').trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
}
let props = {};
let childrenArray;
if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
props = propsOrChildren;
childrenArray = children;
} else {
childrenArray = propsOrChildren;
}
// --- Start of Attribute/Property Handling ---
const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);
for (const [key, value] of Object.entries(props)) {
// 0. Handle `ref` callback (highest priority after props parsing).
if (key === 'ref' && typeof value === 'function') {
value(el);
}
// 1. Security check for URL attributes.
else if (urlAttributes.has(key)) {
const url = String(value);
try {
const parsedUrl = new URL(url); // Throws if not an absolute URL.
if (safeProtocols.has(parsedUrl.protocol)) {
el.setAttribute(key, url);
} else {
el.setAttribute(key, '#');
Logger.warn('UNSAFE URL', LOG_STYLES.YELLOW, `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.warn('INVALID URL', LOG_STYLES.YELLOW, `Blocked invalid or relative URL in attribute "${key}":`, url);
}
}
// 2. Direct property assignments.
else if (directProperties.has(key)) {
el[key] = value;
}
// 3. Other specialized handlers.
else if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (key === 'dataset' && typeof value === 'object') {
for (const [dataKey, dataVal] of Object.entries(value)) {
el.dataset[dataKey] = dataVal;
}
} else if (key.startsWith('on')) {
if (typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
}
} else if (key === 'className') {
const classes = String(value).trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
} else if (key.startsWith('aria-')) {
el.setAttribute(key, String(value));
}
// 4. Default attribute handling.
else if (value !== false && value !== null && typeof value !== 'undefined') {
el.setAttribute(key, value === true ? '' : String(value));
}
}
// --- End of Attribute/Property Handling ---
const fragment = document.createDocumentFragment();
/**
* Appends a child node or text to the document fragment.
* @param {HChild} child - The child to append.
*/
function append(child) {
if (child === null || child === false || typeof child === 'undefined') return;
if (typeof child === 'string' || typeof child === 'number') {
fragment.appendChild(document.createTextNode(String(child)));
} else if (Array.isArray(child)) {
child.forEach(append);
} else if (child instanceof Node) {
fragment.appendChild(child);
} else {
throw new Error('Unsupported child type');
}
}
append(childrenArray);
el.appendChild(fragment);
if (el instanceof HTMLElement || el instanceof SVGElement) {
return el;
}
throw new Error('Created element is not a valid HTMLElement or SVGElement');
}
/**
* Recursively builds a DOM element from a definition object using the h() function.
* @param {object} def The definition object for the element.
* @returns {HTMLElement | SVGElement | null} The created DOM element.
*/
function createIconFromDef(def) {
if (!def) return null;
const children = def.children ? def.children.map((child) => createIconFromDef(child)) : [];
return h(def.tag, def.props, children);
}
// =================================================================================
// SECTION: Event-Driven Architecture (Pub/Sub)
// Description: An event bus for decoupled communication between classes.
// =================================================================================
const EventBus = {
events: {},
uiWorkQueue: [],
isUiWorkScheduled: false,
logPrefix: '[EventBus]',
debug: false,
_logAggregation: {},
_aggregatedEvents: new Set(),
_aggregationDelay: 500, // ms
/**
* Sets the log prefix for this EventBus instance.
* @param {string} prefix The log prefix string.
*/
setLogPrefix(prefix) {
this.logPrefix = prefix;
},
/**
* Sets the debug mode for this EventBus instance.
* @param {boolean} enabled Whether debug mode is enabled.
*/
setDebug(enabled) {
this.debug = enabled;
},
setAggregatedEvents(eventsIterable) {
this._aggregatedEvents = new Set(eventsIterable);
},
/**
* Subscribes a listener to an event using a unique key.
* If a subscription with the same event and key already exists, it will be overwritten.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName').
*/
subscribe(event, listener, key) {
if (!key) {
console.error(`${this.logPrefix} [EventBus] EventBus.subscribe requires a unique key.`);
return;
}
this.events[event] ??= new Map();
this.events[event].set(key, listener);
},
/**
* Subscribes a listener that will be automatically unsubscribed after one execution.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription.
*/
once(event, listener, key) {
if (!key) {
console.error(`${this.logPrefix} [EventBus] EventBus.once requires a unique key.`);
return;
}
const onceListener = (...args) => {
this.unsubscribe(event, key);
return listener(...args);
};
this.subscribe(event, onceListener, key);
},
/**
* Unsubscribes a listener from an event using its unique key.
* @param {string} event The event name.
* @param {string} key The unique key used during subscription.
*/
unsubscribe(event, key) {
if (!this.events[event] || !key) {
return;
}
this.events[event].delete(key);
if (this.events[event].size === 0) {
delete this.events[event];
}
},
/**
* Publishes an event, calling all subscribed listeners with the provided data.
* @param {string} event The event name.
* @param {...unknown} args The data to pass to the listeners.
*/
publish(event, ...args) {
if (!this.events[event]) {
return;
}
if (this.debug) {
// --- Aggregation logic START ---
if (this._aggregatedEvents.has(event)) {
this._logAggregation[event] ??= { timer: null, count: 0 };
const aggregation = this._logAggregation[event];
aggregation.count++;
clearTimeout(aggregation.timer);
aggregation.timer = setTimeout(() => {
const finalCount = this._logAggregation[event]?.count ?? 0;
if (finalCount > 0) {
console.debug(`${this.logPrefix} [EventBus] Event Published: ${event} (x${finalCount})`);
}
delete this._logAggregation[event];
}, this._aggregationDelay);
// Execute subscribers for the aggregated event, but without the verbose individual logs.
[...this.events[event].values()].forEach((listener) => {
try {
const result = listener(...args);
if (result instanceof Promise) {
result.catch((e) => {
console.error(`${this.logPrefix} [EventBus] EventBus async error in listener for event "${event}":`, e);
});
}
} catch (e) {
console.error(`${this.logPrefix} [EventBus] EventBus error in listener for event "${event}":`, e);
}
});
return; // End execution here for aggregated events in debug mode.
}
// --- Aggregation logic END ---
// In debug mode, provide detailed logging for NON-aggregated events.
const subscriberKeys = [...this.events[event].keys()];
console.groupCollapsed(`${this.logPrefix} [EventBus] Event Published: ${event}`);
if (args.length > 0) {
console.log(' - Payload:', ...args);
} else {
console.log(' - Payload: (No data)');
}
// Displaying subscribers helps in understanding the event's impact.
if (subscriberKeys.length > 0) {
console.log(' - Subscribers:\n' + subscriberKeys.map((key) => ` > ${key}`).join('\n'));
} else {
console.log(' - Subscribers: (None)');
}
// Iterate with keys for better logging
for (const [key, listener] of [...this.events[event].entries()]) {
try {
// Log which specific subscriber is being executed
console.debug(`${this.logPrefix} [EventBus] -> Executing: ${key}`);
const result = listener(...args);
if (result instanceof Promise) {
result.catch((e) => {
console.error(`${this.logPrefix} [LISTENER ERROR] Async listener "${key}" failed for event "${event}":`, e);
});
}
} catch (e) {
// Enhance error logging with the specific subscriber key
console.error(`${this.logPrefix} [LISTENER ERROR] Listener "${key}" failed for event "${event}":`, e);
}
}
console.groupEnd();
} else {
// Iterate over a copy of the values in case a listener unsubscribes itself.
[...this.events[event].values()].forEach((listener) => {
try {
const result = listener(...args);
if (result instanceof Promise) {
result.catch((e) => {
console.error(`${this.logPrefix} [LISTENER ERROR] Async listener failed for event "${event}":`, e);
});
}
} catch (e) {
console.error(`${this.logPrefix} [LISTENER ERROR] Listener failed for event "${event}":`, e);
}
});
}
},
/**
* Queues a function to be executed on the next animation frame.
* Batches multiple UI updates into a single repaint cycle.
* @param {Function} workFunction The function to execute.
*/
queueUIWork(workFunction) {
this.uiWorkQueue.push(workFunction);
if (!this.isUiWorkScheduled) {
this.isUiWorkScheduled = true;
requestAnimationFrame(this._processUIWorkQueue.bind(this));
}
},
/**
* @private
* Processes all functions in the UI work queue.
*/
_processUIWorkQueue() {
// Prevent modifications to the queue while processing.
const queueToProcess = [...this.uiWorkQueue];
this.uiWorkQueue.length = 0;
for (const work of queueToProcess) {
try {
const result = work();
if (result instanceof Promise) {
result.catch((e) => {
console.error(`${this.logPrefix} [UI QUEUE ERROR] Async error in queued UI work:`, e);
});
}
} catch (e) {
console.error(`${this.logPrefix} [UI QUEUE ERROR] Error in queued UI work:`, e);
}
}
// Check if new work was added during processing (e.g., from trailing edge handlers)
if (this.uiWorkQueue.length > 0) {
requestAnimationFrame(this._processUIWorkQueue.bind(this));
} else {
this.isUiWorkScheduled = false;
}
},
};
/**
* Creates a unique, consistent event subscription key for EventBus.
* @param {object} context The `this` context of the subscribing class instance.
* @param {string} eventName The full event name from the EVENTS constant.
* @returns {string} A key in the format 'ClassName.purpose'.
*/
function createEventKey(context, eventName) {
// Extract a meaningful 'purpose' from the event name
const parts = eventName.split(':');
const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
let contextName = 'UnknownContext';
if (context && context.constructor && context.constructor.name) {
contextName = context.constructor.name;
}
return `${contextName}.${purpose}`;
}
/**
* Creates a unique consistent subscriber key for NavigationMonitor.
* @param {string} purpose - The purpose identifier from CONSTANTS.NAV_PURPOSE.
* @returns {string} A key in the format '${APPID}-purpose'.
*/
function createSubscriberKey(purpose) {
return `${APPID}-${purpose}`;
}
// =================================================================================
// SECTION: Base Manager
// Description: Provides common lifecycle and event subscription management.
// =================================================================================
/**
* @class BaseManager
* @description Provides common lifecycle and event subscription management capabilities.
* Implements the Template Method pattern for init/destroy cycles.
* Manages all resources in a unified Set to ensure strict LIFO disposal and prevent memory leaks.
*/
class BaseManager {
constructor() {
/**
* @type {Set<AppDisposable>}
* Unified storage for all resources. Set preserves insertion order.
*/
this._disposables = new Set();
/**
* @type {Map<string, () => void>}
* Map to store dispose functions for keyed resources, allowing replacement by key.
*/
this._keyedDisposables = new Map();
this.isInitialized = false;
this.isDestroyed = false;
/** @type {Promise<void>|null} */
this._initPromise = null;
/** @type {AbortController} */
this._abortController = new AbortController();
}
/**
* Gets the AbortSignal associated with this manager's lifecycle.
* Aborted when the manager is destroyed.
* @returns {AbortSignal}
*/
get signal() {
return this._abortController.signal;
}
/**
* Registers a resource to be disposed of when the manager is destroyed.
* @param {AppDisposable} disposable A function or object with dispose/disconnect/abort/destroy method.
* @returns {() => void} A function to dispose of the resource early.
*/
addDisposable(disposable) {
return this._registerDisposable(disposable);
}
/**
* Initializes the manager.
* Prevents double initialization and supports async hooks.
* @param {...unknown} args Arguments to pass to the hook method.
* @returns {Promise<void>}
*/
async init(...args) {
if (this.isInitialized) return;
if (this._initPromise) {
await this._initPromise;
return;
}
this.isDestroyed = false;
if (this._abortController.signal.aborted) {
this._abortController = new AbortController();
}
this._initPromise = (async () => {
try {
await this._onInit(...args);
if (!this.isDestroyed) {
this.isInitialized = true;
}
} catch (e) {
this.destroy();
throw e;
}
})();
try {
await this._initPromise;
} finally {
this._initPromise = null;
}
}
/**
* Destroys the manager and cleans up resources.
* Idempotent: safe to call multiple times.
*/
destroy() {
if (this.isDestroyed) return;
this.isDestroyed = true;
this.isInitialized = false;
this._abortController.abort();
// 1. Hook for subclass specific cleanup (protected by try-catch)
try {
this._onDestroy();
} catch (e) {
Logger.error('BaseManager', '', 'Error in _onDestroy:', e);
}
// 2. Dispose all resources in LIFO order
// Convert Set to Array and reverse to ensure correct dependency teardown order.
const disposables = Array.from(this._disposables).reverse();
this._disposables.clear(); // Clear immediately to prevent double disposal
this._keyedDisposables.clear(); // Clear keyed map
for (const resource of disposables) {
this._disposeResource(resource);
}
}
/**
* Registers a platform-specific listener.
* @param {string} event
* @param {Function} callback
* @returns {() => void} A function to unsubscribe.
*/
registerPlatformListener(event, callback) {
return this._subscribe(event, callback);
}
/**
* Registers a one-time platform-specific listener.
* @param {string} event
* @param {Function} callback
* @returns {() => void} A function to unsubscribe (if not already fired).
*/
registerPlatformListenerOnce(event, callback) {
return this._subscribeOnce(event, callback);
}
/**
* Manages a dynamic resource by key.
* Replaces any existing resource registered with the same key.
* If null is passed as the resource, the existing resource (if any) is disposed and the key is removed; no new resource is registered.
* @param {string} key Unique identifier.
* @param {AppDisposable | null} resource The new resource. Pass null to remove existing without replacing.
* @returns {() => void} A function to dispose of the resource early.
*/
manageResource(key, resource) {
// 1. Dispose of existing resource with the same key, if any
if (this._keyedDisposables.has(key)) {
const oldDispose = this._keyedDisposables.get(key);
if (oldDispose) oldDispose();
// Ensure it's removed (oldDispose should handle it via wrapper, but for safety)
this._keyedDisposables.delete(key);
}
// 2. Register new resource
if (resource) {
// Register with the main set to handle LIFO disposal on destroy
const actualDispose = this._registerDisposable(resource);
// Create a wrapper that removes the entry from the map when disposed
const wrappedDispose = () => {
if (this._keyedDisposables.get(key) === wrappedDispose) {
this._keyedDisposables.delete(key);
}
actualDispose();
};
this._keyedDisposables.set(key, wrappedDispose);
return wrappedDispose;
}
return () => {};
}
/**
* Manages a dynamic resource created by a factory function.
* @template {AppDisposable} T
* @param {string} key Unique identifier for the resource.
* @param {() => T} factory A function that returns the resource.
* @returns {T | null} The created resource, or null if destroyed.
* @throws {Error} Propagates any error thrown by the factory function.
*/
manageFactory(key, factory) {
if (!this.isDestroyed) {
// 1. Dispose of existing resource with the same key
if (this._keyedDisposables.has(key)) {
const oldDispose = this._keyedDisposables.get(key);
if (oldDispose) oldDispose();
this._keyedDisposables.delete(key);
}
const resource = factory();
if (resource) {
const actualDispose = this._registerDisposable(resource);
const wrappedDispose = () => {
if (this._keyedDisposables.get(key) === wrappedDispose) {
this._keyedDisposables.delete(key);
}
actualDispose();
};
this._keyedDisposables.set(key, wrappedDispose);
return resource;
}
}
return null;
}
/**
* Hook method for initialization logic.
* @protected
* @param {...unknown} args
* @returns {void | Promise<void>}
*/
_onInit(...args) {
// To be implemented by subclasses
}
/**
* Hook method for cleanup logic.
* @protected
*/
_onDestroy() {
// To be implemented by subclasses
}
/**
* Helper to subscribe to EventBus.
* @protected
* @param {string} event
* @param {Function} listener
* @returns {() => void} A function to unsubscribe.
*/
_subscribe(event, listener) {
if (this.isDestroyed) return () => {};
// Wrap listener to guard against execution after destruction
const guardedListener = (...args) => {
if (this.isDestroyed) return;
return listener(...args);
};
const baseKey = createEventKey(this, event);
const listenerName = listener.name || 'anonymous';
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, guardedListener, key);
// Create a cleanup task
const cleanup = () => EventBus.unsubscribe(event, key);
return this._registerDisposable(cleanup);
}
/**
* Helper to subscribe to EventBus once.
* @protected
* @param {string} event
* @param {Function} listener
* @returns {() => void} A function to unsubscribe.
*/
_subscribeOnce(event, listener) {
if (this.isDestroyed) return () => {};
// Wrap listener to guard against execution after destruction
const guardedListener = (...args) => {
if (this.isDestroyed) return;
return listener(...args);
};
const baseKey = createEventKey(this, event);
const listenerName = listener.name || 'anonymous';
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
// Define cleanup first to establish dependency chain
const cleanup = () => EventBus.unsubscribe(event, key);
// Register disposable immediately to allow using 'const' and avoid TDZ
const disposeFn = this._registerDisposable(cleanup);
// Self-cleaning listener wrapper
const wrappedListener = (...args) => {
// Execute dispose to remove from manager and avoid memory leaks
disposeFn();
return guardedListener(...args);
};
EventBus.once(event, wrappedListener, key);
return disposeFn;
}
/**
* Internal helper to register a resource into the Set and return a safe dispose function.
* @private
* @param {AppDisposable} resource
* @returns {() => void} A function that disposes the resource and removes it from the manager.
*/
_registerDisposable(resource) {
if (!resource) return () => {};
// If already destroyed, dispose immediately and return no-op
if (this.isDestroyed) {
this._disposeResource(resource);
return () => {};
}
this._disposables.add(resource);
let disposed = false;
// Return an idempotent dispose function
return () => {
if (disposed) return;
disposed = true;
if (this._disposables.has(resource)) {
this._disposables.delete(resource);
this._disposeResource(resource);
}
};
}
/**
* Helper to safely dispose a resource of various types.
* Execution priority:
* 1. Function call
* 2. AbortController.abort()
* 3. Observer.disconnect() (Mutation/Resize/Intersection)
* 4. object.dispose()
* 5. object.disconnect()
* 6. object.abort()
* 7. object.destroy()
* @private
* @param {AppDisposable} disposable
*/
_disposeResource(disposable) {
try {
if (typeof disposable === 'function') {
disposable();
} else if (disposable instanceof AbortController) {
disposable.abort();
} else if (disposable instanceof MutationObserver || disposable instanceof ResizeObserver || (typeof IntersectionObserver !== 'undefined' && disposable instanceof IntersectionObserver)) {
disposable.disconnect();
} else if (this._isDisposableObj(disposable)) {
disposable.dispose();
} else if (this._isDisconnectableObj(disposable)) {
disposable.disconnect();
} else if (this._isAbortableObj(disposable)) {
disposable.abort();
} else if (this._isDestructibleObj(disposable)) {
disposable.destroy();
}
} catch (e) {
Logger.warn('BaseManager', '', 'Error disposing resource type:', e);
}
}
// --- Type Guards ---
/**
* @param {unknown} obj
* @returns {obj is AppDestructibleObj}
*/
_isDestructibleObj(obj) {
return typeof obj === 'object' && obj !== null && 'destroy' in obj && typeof (/** @type {{ destroy: unknown }} */ (obj).destroy) === 'function';
}
/**
* @param {unknown} obj
* @returns {obj is AppDisposableObj}
*/
_isDisposableObj(obj) {
return typeof obj === 'object' && obj !== null && 'dispose' in obj && typeof (/** @type {{ dispose: unknown }} */ (obj).dispose) === 'function';
}
/**
* @param {unknown} obj
* @returns {obj is AppDisconnectableObj}
*/
_isDisconnectableObj(obj) {
return typeof obj === 'object' && obj !== null && 'disconnect' in obj && typeof (/** @type {{ disconnect: unknown }} */ (obj).disconnect) === 'function';
}
/**
* @param {unknown} obj
* @returns {obj is AppAbortableObj}
*/
_isAbortableObj(obj) {
return typeof obj === 'object' && obj !== null && 'abort' in obj && typeof (/** @type {{ abort: unknown }} */ (obj).abort) === 'function';
}
}
// =================================================================================
// SECTION: Configuration Management
// =================================================================================
class ConfigManager {
constructor() {
this.config = null;
}
async load() {
const raw = await GM.getValue(CONSTANTS.CONFIG_KEY, null);
this.config = resolveConfig(deepClone(DEFAULT_CONFIG), raw ? JSON.parse(raw) : {});
Logger.setLevel(this.config.developer.logger_level);
EventBus.setLogPrefix(LOG_PREFIX);
EventBus.setDebug(this.config.developer.logger_level === 'debug');
}
async save(newConfig) {
this.config = resolveConfig(deepClone(DEFAULT_CONFIG), newConfig);
await GM.setValue(CONSTANTS.CONFIG_KEY, JSON.stringify(this.config));
EventBus.publish(EVENTS.CONFIG_UPDATED, this.config);
}
get() {
return this.config || deepClone(DEFAULT_CONFIG);
}
}
// =================================================================================
// SECTION: Settings Modal
// =================================================================================
class SettingsModal extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {object} siteStyles - The style definition object.
*/
constructor(configManager, siteStyles) {
super();
this.configManager = configManager;
this.siteStyles = siteStyles;
this.overlay = null;
// Bind the keydown handler once to ensure consistent reference for add/removeEventListener
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
}
/**
* Opens the settings modal.
*/
open() {
if (this.overlay) return;
// Inject styles dynamically using the provided siteStyles
this._injectStyles();
const config = this.configManager.get();
// --- Helper: Dynamic Description Maps ---
// Referrer Policy Descriptions
const referrerDescMap = {
[CONSTANTS.REFERRER_POLICY.NO_REFERRER]: {
text: 'No referrer information is sent. Maximum privacy, but many images or pages may fail to load due to anti-hotlink protection.',
color: 'inherit',
},
[CONSTANTS.REFERRER_POLICY.ORIGIN]: {
text: 'Only the domain name is sent. Balances privacy and functionality, though some strict sites may still block access.',
color: 'rgb(129 201 149)',
},
[CONSTANTS.REFERRER_POLICY.UNSAFE_URL]: {
text: 'The full URL including search queries is sent. Highest compatibility, but exposes your search data to the site.',
color: 'rgb(255 138 128)',
},
};
const createReferrerDesc = (val) => {
const info = referrerDescMap[val] || referrerDescMap[CONSTANTS.REFERRER_POLICY.ORIGIN];
return h(`div#${APPID}-referrer-desc`, { style: { marginTop: '4px', color: info.color } }, info.text);
};
// Fetch Strategy Descriptions
const strategyDescMap = {
[CONSTANTS.FETCH_STRATEGY.AUTO]: 'Checks headers first. Uses Blob mode (hidden URL) if forced download is detected. Adds slight delay.',
[CONSTANTS.FETCH_STRATEGY.BLOB]: "Loads images as 'blob:' URLs. Fast, but the original URL is hidden (visible only in Console). Consumes memory.",
[CONSTANTS.FETCH_STRATEGY.DIRECT]: 'Opens the URL directly. Zero overhead, but may trigger forced downloads.',
};
const createStrategyDesc = (val) => {
const text = strategyDescMap[val] || strategyDescMap[CONSTANTS.FETCH_STRATEGY.AUTO];
return h(`div#${APPID}-strategy-desc`, { style: { marginTop: '4px', color: 'rgb(154 160 166)' } }, text);
};
// --- Helper: Interaction Logic ---
const updateRetryState = (strategy) => {
const retryWrapper = document.getElementById(`${APPID}-retry-wrapper`);
const retryInput = document.getElementById(`${APPID}-input-retry`);
const retryDesc = document.getElementById(`${APPID}-retry-desc`);
if (!retryWrapper || !retryInput) return;
const isDisabled = strategy === CONSTANTS.FETCH_STRATEGY.DIRECT;
const opacity = isDisabled ? '0.5' : '1';
retryInput.disabled = isDisabled;
retryWrapper.style.opacity = opacity;
retryWrapper.style.pointerEvents = isDisabled ? 'none' : 'auto';
if (retryDesc) {
retryDesc.style.opacity = opacity;
}
};
// --- Modal Construction ---
this.overlay = h(
`div.${APPID}-modal-overlay`,
{
onclick: (e) => {
if (e.target === this.overlay) this.close();
},
},
[
h(`div.${APPID}-modal-box`, [
// Header
h(`div.${APPID}-modal-header`, [h('span', `${APPNAME} Settings`)]),
// Content
h(`div.${APPID}-modal-content`, [
// Group 1: Appearance
this._createFormGroup('Appearance', '', [
h(
`label.${APPID}-checkbox-wrapper`,
{
title: 'Reduces visual clutter by hiding buttons until you hover over an image result.',
},
[
h(`input#${APPID}-input-hover`, {
type: 'checkbox',
checked: config.common.showOnlyOnHover,
}),
h('span', 'Show buttons only on hover'),
]
),
h(
`label.${APPID}-checkbox-wrapper`,
{
title: 'Displays the globe button that takes you directly to the webpage hosting the image.',
},
[
h(`input#${APPID}-input-page-btn`, {
type: 'checkbox',
checked: config.common.showVisitPageButton,
}),
h('span', 'Show "Visit Page" button'),
]
),
]),
h('hr', { style: { border: '0', borderTop: '1px solid rgb(95 99 104)', margin: '8px 0 16px 0' } }),
// Group 2: Network & Privacy
this._createFormGroup('Network & Privacy', '', [
// 1. Fetch Strategy
h(
`label.${APPID}-form-label`,
{
style: { fontWeight: 'normal', marginBottom: '4px' },
title: 'Controls how images are fetched and opened.',
},
'Fetch Strategy'
),
h(
`select#${APPID}-input-strategy.${APPID}-form-select`,
{
onchange: (e) => {
const val = e.target.value;
const descEl = document.getElementById(`${APPID}-strategy-desc`);
if (descEl) {
descEl.textContent = strategyDescMap[val] || strategyDescMap[CONSTANTS.FETCH_STRATEGY.AUTO];
}
updateRetryState(val);
},
title: 'Select the strategy for fetching and opening images.',
},
[
h('option', { value: CONSTANTS.FETCH_STRATEGY.AUTO, selected: config.common.fetchStrategy === CONSTANTS.FETCH_STRATEGY.AUTO }, 'Auto Detect (Default)'),
h('option', { value: CONSTANTS.FETCH_STRATEGY.BLOB, selected: config.common.fetchStrategy === CONSTANTS.FETCH_STRATEGY.BLOB }, 'Always Blob (Fast)'),
h('option', { value: CONSTANTS.FETCH_STRATEGY.DIRECT, selected: config.common.fetchStrategy === CONSTANTS.FETCH_STRATEGY.DIRECT }, 'Always Direct'),
]
),
createStrategyDesc(config.common.fetchStrategy || CONSTANTS.FETCH_STRATEGY.AUTO),
// 2. Referrer Policy
h(
`label.${APPID}-form-label`,
{
style: { fontWeight: 'normal', marginBottom: '4px', marginTop: '16px' },
title: 'Controls referrer data sent to the destination. Balances privacy with image loading success.',
},
'Referrer Policy'
),
h(
`select#${APPID}-input-referrer.${APPID}-form-select`,
{
onchange: (e) => {
const descEl = document.getElementById(`${APPID}-referrer-desc`);
if (descEl) {
const info = referrerDescMap[e.target.value] || referrerDescMap[CONSTANTS.REFERRER_POLICY.ORIGIN];
descEl.textContent = info.text;
descEl.style.color = info.color;
}
},
title: 'Controls what information is sent to the destination site.',
},
[
h('option', { value: CONSTANTS.REFERRER_POLICY.NO_REFERRER, selected: config.common.referrerPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER }, 'No Referrer'),
h('option', { value: CONSTANTS.REFERRER_POLICY.ORIGIN, selected: config.common.referrerPolicy === CONSTANTS.REFERRER_POLICY.ORIGIN || !config.common.referrerPolicy }, 'Origin Only (Default)'),
h('option', { value: CONSTANTS.REFERRER_POLICY.UNSAFE_URL, selected: config.common.referrerPolicy === CONSTANTS.REFERRER_POLICY.UNSAFE_URL }, 'Full URL'),
]
),
createReferrerDesc(config.common.referrerPolicy || CONSTANTS.REFERRER_POLICY.ORIGIN),
// 3. Retry on failure
h('div', { style: { marginTop: '16px' } }, [
h(
`label#${APPID}-retry-wrapper.${APPID}-checkbox-wrapper`,
{
title: 'Automatically retries with an alternative referrer policy if the initial attempt fails.',
style: { transition: 'opacity 0.2s' },
},
[
h(`input#${APPID}-input-retry`, {
type: 'checkbox',
checked: config.common.retryOnFailure,
}),
h('span', 'Retry on failure'),
]
),
h(`div#${APPID}-retry-desc.${APPID}-form-desc`, { style: { marginLeft: '24px', transition: 'opacity 0.2s' } }, 'Automatically retries with an alternative referrer policy if the initial attempt fails.'),
]),
]),
h('hr', { style: { border: '0', borderTop: '1px solid rgb(95 99 104)', margin: '8px 0 16px 0' } }),
// Group 3: Advanced Settings
this._createFormGroup('Advanced Settings', '', [
h(
`label.${APPID}-form-label`,
{
style: { fontWeight: 'normal', marginBottom: '4px' },
title: 'Determines how long the image data is kept in memory.',
},
'Blob URL Revoke Time'
),
h(
`select#${APPID}-input-revoke-time.${APPID}-form-select`,
{
title: 'Determines how long the image data is kept in memory.',
},
[
h('option', { value: 60000, selected: Number(config.common.blobRevokeTimeout) === 60000 }, '1 Minute (Low Memory)'),
h('option', { value: 300000, selected: Number(config.common.blobRevokeTimeout) === 300000 }, '5 Minutes'),
h('option', { value: 600000, selected: Number(config.common.blobRevokeTimeout) === 600000 || !config.common.blobRevokeTimeout }, '10 Minutes (Default)'),
h('option', { value: 1800000, selected: Number(config.common.blobRevokeTimeout) === 1800000 }, '30 Minutes'),
h('option', { value: 3600000, selected: Number(config.common.blobRevokeTimeout) === 3600000 }, '1 Hour (High Memory)'),
]
),
h(`div.${APPID}-form-desc`, { style: { marginTop: '4px' } }, 'Time to hold image data in memory. Increase this if images fail to load when viewing background tabs after a delay.'),
]),
]),
// Footer
h(`div.${APPID}-modal-footer`, [
// Left: Restore Defaults
h(`button.${APPID}-ui-btn.${APPID}-btn-secondary`, { onclick: () => this._restoreDefaults() }, 'Restore Defaults'),
// Right: Actions
h(`div.${APPID}-footer-actions`, [h(`button.${APPID}-ui-btn.${APPID}-btn-secondary`, { onclick: () => this.close() }, 'Cancel'), h(`button.${APPID}-ui-btn.${APPID}-btn-primary`, { onclick: () => this.save() }, 'Save')]),
]),
]),
]
);
document.body.appendChild(this.overlay);
// Initialize Retry state based on current strategy
updateRetryState(config.common.fetchStrategy || CONSTANTS.FETCH_STRATEGY.AUTO);
// Add global key listener for ESC
document.addEventListener('keydown', this._boundHandleKeyDown);
this.addDisposable(() => document.removeEventListener('keydown', this._boundHandleKeyDown));
}
/**
* Closes the settings modal.
*/
close() {
this.destroy();
}
/**
* Hook method for cleanup logic.
* @protected
*/
_onDestroy() {
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
}
/**
* Saves the current settings from the form.
*/
async save() {
const newConfig = this.configManager.get();
// Collect values from DOM
newConfig.common.showOnlyOnHover = document.getElementById(`${APPID}-input-hover`).checked;
newConfig.common.showVisitPageButton = document.getElementById(`${APPID}-input-page-btn`).checked;
newConfig.common.fetchStrategy = document.getElementById(`${APPID}-input-strategy`).value;
newConfig.common.referrerPolicy = document.getElementById(`${APPID}-input-referrer`).value;
newConfig.common.retryOnFailure = document.getElementById(`${APPID}-input-retry`).checked;
newConfig.common.blobRevokeTimeout = Number(document.getElementById(`${APPID}-input-revoke-time`).value);
await this.configManager.save(newConfig);
this.close();
}
/**
* Restores default settings to the form inputs.
* @private
*/
_restoreDefaults() {
// Restore Checkboxes
document.getElementById(`${APPID}-input-hover`).checked = DEFAULT_CONFIG.common.showOnlyOnHover;
document.getElementById(`${APPID}-input-page-btn`).checked = DEFAULT_CONFIG.common.showVisitPageButton;
// Strategy (Triggers Retry state update via change event)
const strategyInput = document.getElementById(`${APPID}-input-strategy`);
strategyInput.value = DEFAULT_CONFIG.common.fetchStrategy;
strategyInput.dispatchEvent(new Event('change'));
// Referrer (Triggers description update)
const referrerInput = document.getElementById(`${APPID}-input-referrer`);
referrerInput.value = DEFAULT_CONFIG.common.referrerPolicy;
referrerInput.dispatchEvent(new Event('change'));
// Retry
document.getElementById(`${APPID}-input-retry`).checked = DEFAULT_CONFIG.common.retryOnFailure;
// Advanced
document.getElementById(`${APPID}-input-revoke-time`).value = DEFAULT_CONFIG.common.blobRevokeTimeout;
}
/**
* Handles global keydown events.
* @private
*/
_handleKeyDown(e) {
if (e.key === 'Escape') {
this.close();
}
}
/**
* Helper to create a labeled form group with indented content.
* @private
*/
_createFormGroup(label, desc, control) {
return h(`div.${APPID}-form-group`, [h(`label.${APPID}-form-label`, label), h(`div.${APPID}-indent-content`, [control, desc ? h(`div.${APPID}-form-desc`, desc) : null])]);
}
/**
* Injects the modal styles dynamically.
* Enforces a fixed Dark Mode theme as requested.
* @private
*/
_injectStyles() {
const id = `${APPID}-modal-dynamic-styles`;
if (document.getElementById(id)) return;
// Fixed Dark Mode Palette (Google-like Dark Theme)
const css = `
.${APPID}-modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgb(0 0 0 / 0.6);
z-index: ${CONSTANTS.MODAL.Z_INDEX};
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(2px);
}
.${APPID}-modal-box {
background: rgb(32 33 36);
color: rgb(232 234 237);
width: ${CONSTANTS.MODAL.WIDTH}px;
max-width: 90vw;
max-height: 90vh; /* Limit height to viewport */
border: 1px solid rgb(95 99 104);
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.5);
display: flex; flex-direction: column;
font-family: Roboto, Arial, sans-serif; font-size: 14px;
color-scheme: dark;
}
.${APPID}-modal-header {
padding: 12px 16px;
font-size: 1.1em; font-weight: bold;
border-bottom: 1px solid rgb(95 99 104);
display: flex; justify-content: space-between; align-items: center;
background: rgb(32 33 36);
border-radius: 8px 8px 0 0;
flex-shrink: 0; /* Prevent shrinking */
}
.${APPID}-modal-content {
padding: 16px;
overflow-y: auto;
flex: 1; /* Fill remaining space */
min-height: 0; /* Enable scrolling inside flex item */
}
.${APPID}-modal-footer {
padding: 12px 16px;
border-top: 1px solid rgb(95 99 104);
display: flex; justify-content: space-between;
align-items: center;
background: rgb(32 33 36);
border-radius: 0 0 8px 8px;
flex-shrink: 0; /* Prevent shrinking */
}
.${APPID}-footer-actions {
display: flex; gap: 8px;
}
.${APPID}-form-group {
margin-bottom: 16px;
}
.${APPID}-form-label {
display: block; margin-bottom: 8px; font-weight: 600; color: rgb(232 234 237);
}
.${APPID}-indent-content {
margin-left: 12px;
display: flex; flex-direction: column;
gap: 8px;
}
.${APPID}-form-desc {
color: rgb(154 160 166); margin-bottom: 6px; line-height: 1.4;
}
.${APPID}-form-input {
width: 100%; padding: 6px 8px;
background: rgb(48 49 52); border: 1px solid rgb(95 99 104); border-radius: 4px;
color: rgb(232 234 237); box-sizing: border-box;
}
.${APPID}-form-input:focus {
border-color: rgb(138 180 248); outline: 1px solid rgb(138 180 248);
}
.${APPID}-form-select {
width: 100%; padding: 6px 8px;
background: rgb(48 49 52); border: 1px solid rgb(95 99 104); border-radius: 4px;
color: rgb(232 234 237); box-sizing: border-box;
cursor: pointer;
}
.${APPID}-form-select:focus {
border-color: rgb(138 180 248); outline: 1px solid rgb(138 180 248);
}
.${APPID}-checkbox-wrapper {
display: flex; align-items: center; gap: 8px; color: rgb(232 234 237);
}
.${APPID}-ui-btn {
padding: 6px 16px; border-radius: 4px; border: 1px solid rgb(95 99 104);
cursor: pointer; font-size: 13px; font-weight: 500;
transition: background 0.1s;
white-space: nowrap;
}
.${APPID}-btn-primary {
background: rgb(138 180 248); color: rgb(32 33 36); border: 1px solid rgb(138 180 248);
}
.${APPID}-btn-primary:hover {
opacity: 0.9;
}
.${APPID}-btn-secondary {
background: rgb(48 49 52); color: rgb(232 234 237);
}
.${APPID}-btn-secondary:hover {
background: rgb(60 64 67);
}
/* Mobile Responsive */
@media (max-width: 480px) {
.${APPID}-modal-box {
width: 95vw;
max-height: 95vh;
}
}
`;
const style = h('style', { id }, css);
// Add nonce if available
const nonce = document.querySelector('script[nonce]')?.nonce;
if (nonce) style.setAttribute('nonce', nonce);
document.head.appendChild(style);
}
}
// =================================================================================
// SECTION: UI Manager
// =================================================================================
class UIManager extends BaseManager {
/**
* @param {ConfigManager} configManager
*/
constructor(configManager) {
super();
this.configManager = configManager;
this.toastContainer = null;
this.imageBtnTemplate = null;
this.pageBtnTemplate = null;
this.urlFetcher = null;
/**
* Stores state associated with button elements without polluting the DOM.
* Key: Button Element
* Value: { sentinel: HTMLElement, isFetching: boolean, activeBlobUrl: string|null, activeRevokeTimer: number|null, hostSource: string|null }
* @type {WeakMap<HTMLElement, object>}
*/
this.btnState = new WeakMap();
// Pre-bind event handlers to avoid closure creation per button
this.handleImageClick = this._handleImageClick.bind(this);
this.handlePageClick = this._handlePageClick.bind(this);
this.handleHoverOrFocus = this._handleHoverOrFocus.bind(this);
this.handleMouseDown = this._handleMouseDown.bind(this);
this.stopProp = this._stopProp.bind(this);
this.registerPlatformListener(EVENTS.CONFIG_UPDATED, () => this.updateStyles());
}
/**
* Initializes the UI Manager with platform-specific styles.
* @param {object} platformStyles - The style configuration for the current platform.
*/
init(platformStyles) {
this._injectStyles(platformStyles);
this.updateStyles();
this._createToastContainer();
// Prepare the button templates once for performance (cloneNode usage)
const commonProps = {
target: '_blank',
rel: 'noopener noreferrer',
referrerpolicy: 'no-referrer',
// Reset text decoration and force color to avoid "visited" link style issues
style: { textDecoration: 'none' }, // Color assignment delegated to CSS class
draggable: 'false',
};
// 1. Image Button Template
this.imageBtnTemplate = h(`a.${APPID}-icon-btn.${APPID}-btn-view-image`, { ...commonProps, title: 'View Image' }, [createIconFromDef(CONSTANTS.ICONS.IMAGE)]);
this.imageBtnTemplate.setAttribute('href', '#');
// 2. Page Button Template
this.pageBtnTemplate = h(`a.${APPID}-icon-btn.${APPID}-btn-visit-page`, { ...commonProps, title: 'Visit Page' }, [createIconFromDef(CONSTANTS.ICONS.GLOBE)]);
this.pageBtnTemplate.setAttribute('href', '#');
// Clear all active toasts instantly when the page visibility shifts to background
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden' && this.toastContainer) {
this.toastContainer.textContent = '';
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
this.addDisposable(() => document.removeEventListener('visibilitychange', handleVisibilityChange));
}
/**
* Registers the function used to extract URLs from a container.
* @param {Function} fetcherFn - Function that takes a container element and returns { imageUrl, hostUrl, hostUrlSource }.
*/
setUrlFetcher(fetcherFn) {
this.urlFetcher = fetcherFn;
}
/**
* Registers the function used to asynchronously fetch original image URLs.
* @param {Function} fetcherFn - Async function that takes a sentinel element and returns a Promise resolving to the URL.
*/
setOriginalImageFetcher(fetcherFn) {
this.originalImageFetcher = fetcherFn;
}
/**
* Injects base styles and platform-specific CSS variables.
* @param {object} platformStyles
*/
_injectStyles(platformStyles) {
const id = `${APPID}-styles`;
let style = document.getElementById(id);
// 1. Generate CSS Variables from platformStyles.vars
let varDef = ':root {\n';
if (platformStyles && platformStyles.vars) {
for (const [key, val] of Object.entries(platformStyles.vars)) {
varDef += ` ${key}: ${val};\n`;
}
}
varDef += '}\n';
// 2. Add Dark Mode Overrides if present
if (platformStyles && platformStyles.css_overrides) {
varDef += platformStyles.css_overrides;
}
const cssContent = varDef + UI_STYLES.BASE;
if (style) {
// Update existing style content for platform switching
style.textContent = cssContent;
return;
}
style = h('style', { id }, cssContent);
// Add nonce if available (CSP fix)
const nonce = document.querySelector('script[nonce]')?.nonce;
if (nonce) style.setAttribute('nonce', nonce);
document.head.appendChild(style);
}
updateStyles() {
const config = this.configManager.get();
const id = `${APPID}-dynamic-styles`;
// Update global state on body for CSS-based color control
// dataset uses camelCase: data-gidv-referrer-policy -> gidvReferrerPolicy
// We namespace it to avoid conflicts
const datasetKey = `${APPID}ReferrerPolicy`;
document.body.dataset[datasetKey] = config.common.referrerPolicy;
// Remove existing dynamic styles to re-apply
let styleEl = document.getElementById(id);
if (!styleEl) {
styleEl = h('style', { id: id, type: 'text/css' });
// Add nonce if available
const nonce = document.querySelector('script[nonce]')?.nonce;
if (nonce) styleEl.setAttribute('nonce', nonce);
document.head.appendChild(styleEl);
}
let css = '';
// 1. Hover Logic
css += config.common.showOnlyOnHover ? UI_STYLES.HOVER_ENABLE : UI_STYLES.HOVER_DISABLE;
// 2. Visit Page Button Visibility
if (!config.common.showVisitPageButton) {
css += `.${APPID}-btn-visit-page { display: none !important; }`;
}
styleEl.textContent = css;
}
_createToastContainer() {
// Check if the container already exists to prevent DOM leaks
const existingContainer = document.querySelector(`.${APPID}-toast-container`);
if (existingContainer) {
this.toastContainer = existingContainer;
return;
}
this.toastContainer = h(`div.${APPID}-toast-container`);
document.body.appendChild(this.toastContainer);
}
showToast(message, type = 'info') {
if (!this.toastContainer) return;
const toast = h(`div.${APPID}-toast.${APPID}-toast-${type}`, message);
this.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), CONSTANTS.TOAST_FADE_OUT_DURATION);
}, CONSTANTS.TOAST_DURATION);
}
/**
* Attaches the buttons to the target container.
* @param {HTMLElement} sentinelElement - The element detected by Sentinel (used for duplicate check and data source).
* @param {HTMLElement} targetContainer - The element where buttons will be inserted.
*/
attachButtons(sentinelElement, targetContainer) {
// Prevent duplicates using a specific processed class on the sentinel
const processedClass = CONSTANTS.PROCESSED_CLASS;
// NOTE: Even if the class exists, we might need to re-attach buttons if they were removed by the host.
// But if the class exists AND buttons exist, we typically skip.
// However, with the :not() selector strategy, this method is called only when the class is MISSING.
// So we can proceed to add the class and manage buttons.
if (sentinelElement.classList.contains(processedClass)) {
// If the class is present, checks if buttons are actually there (Paranoid check)
const hasImgBtn = targetContainer.querySelector(`.${APPID}-btn-view-image`);
const hasPageBtn = targetContainer.querySelector(`.${APPID}-btn-visit-page`);
if (hasImgBtn && hasPageBtn) return;
}
// Mark the specific element as processed
sentinelElement.classList.add(processedClass);
// Add the class used for hover effects to the container that holds the button.
targetContainer.classList.add(`${APPID}-container`);
// Create buttons from template
const imgBtn = this.imageBtnTemplate.cloneNode(true);
const pageBtn = this.pageBtnTemplate.cloneNode(true);
// Initialize state in WeakMap
const initialState = {
sentinel: sentinelElement,
isFetching: false,
activeBlobUrl: null,
activeRevokeTimer: null,
activeThumbnailUrl: null,
hostSource: null,
};
this.btnState.set(imgBtn, { ...initialState });
this.btnState.set(pageBtn, { ...initialState });
// Bind events for Image Button
imgBtn.addEventListener('mouseenter', this.handleHoverOrFocus);
imgBtn.addEventListener('focus', this.handleHoverOrFocus);
imgBtn.addEventListener('mousedown', this.handleMouseDown);
imgBtn.addEventListener('mouseup', this.stopProp);
imgBtn.addEventListener('click', this.handleImageClick);
imgBtn.addEventListener('auxclick', this.handleImageClick);
// Bind events for Page Button
pageBtn.addEventListener('mouseenter', this.handleHoverOrFocus);
pageBtn.addEventListener('focus', this.handleHoverOrFocus);
pageBtn.addEventListener('mousedown', this.handleMouseDown);
pageBtn.addEventListener('mouseup', this.stopProp);
pageBtn.addEventListener('click', this.handlePageClick);
pageBtn.addEventListener('auxclick', this.handlePageClick);
// [Resilience] Smart Attach: Replace existing buttons if they exist to prevent flickering, otherwise append.
const mountButton = (newBtn, btnClass) => {
const existingBtn = targetContainer.querySelector(`.${btnClass}`);
if (existingBtn) {
existingBtn.replaceWith(newBtn);
} else {
targetContainer.appendChild(newBtn);
}
};
mountButton(imgBtn, `${APPID}-btn-view-image`);
mountButton(pageBtn, `${APPID}-btn-visit-page`);
}
/**
* Handles mouseenter and focus events.
* Updates the URL but DOES NOT stop propagation, allowing the host site's scripts to detect the user.
* @param {Event} e
*/
_handleHoverOrFocus(e) {
const btn = e.currentTarget;
if (btn instanceof HTMLAnchorElement) {
this._updateButtonHref(btn);
}
}
/**
* Handles mousedown event.
* Updates the URL without stopping propagation to allow host site scripts to execute.
* @param {Event} e
*/
_handleMouseDown(e) {
const btn = e.currentTarget;
if (btn instanceof HTMLAnchorElement) {
this._updateButtonHref(btn);
}
}
/**
* Waits for the URL to be injected by the host site's scripts.
* Uses MutationObserver to detect changes in the anchor tag's href attribute.
* @param {HTMLElement} btn
* @returns {Promise<void>}
*/
_waitForValidUrl(btn) {
return new Promise((resolve) => {
const state = this.btnState.get(btn);
if (!state || !state.sentinel) {
resolve();
return;
}
// Identify the anchor tag that receives the URL (sentinel is usually inside it or related)
const link = state.sentinel.closest('a');
if (!link) {
resolve();
return;
}
const observer = new MutationObserver(() => {
this._updateButtonHref(btn);
if (btn.getAttribute('href')) {
observer.disconnect();
resolve();
}
});
observer.observe(link, { attributes: true, attributeFilter: ['href', 'data-href', 'jsaction'] });
// Timeout safety
setTimeout(() => {
observer.disconnect();
resolve();
}, CONSTANTS.WAIT_FOR_VALID_URL_TIMEOUT); // 500ms max wait
});
}
/**
* Stops event propagation.
* @param {Event} e
*/
_stopProp(e) {
e.stopPropagation();
}
/**
* Updates the href and title of the button based on current DOM state.
* @param {HTMLAnchorElement} btn
*/
_updateButtonHref(btn) {
const state = this.btnState.get(btn);
if (!this.urlFetcher || !state || !state.sentinel) return;
// Fetch URLs on-demand from the current state of the DOM
const { imageUrl, hostUrl, hostUrlSource, thumbnailUrl } = this.urlFetcher(state.sentinel);
const config = this.configManager.get();
const currentPolicy = config.common.referrerPolicy;
let policyChanged = false;
// Update attributes only if policy has changed (Performance Optimization)
if (btn.getAttribute('referrerpolicy') !== currentPolicy) {
btn.setAttribute('referrerpolicy', currentPolicy);
const relValue = currentPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER ? 'noopener noreferrer' : 'noopener';
btn.setAttribute('rel', relValue);
policyChanged = true;
}
const isImageBtn = btn.classList.contains(`${APPID}-btn-view-image`);
if (isImageBtn) {
if (imageUrl) {
if (btn.getAttribute('href') !== imageUrl || policyChanged) {
btn.href = imageUrl;
btn.title = 'View Image';
}
// Store thumbnail URL in state for fallback
state.activeThumbnailUrl = thumbnailUrl;
} else {
// Handling for Async Fetchers (Google/DDG)
// If an async fetcher is registered, we assume the URL might be found later via interaction.
// We keep the thumbnail URL for potential fallback usage.
if (this.originalImageFetcher) {
btn.removeAttribute('href');
btn.title = 'View Image'; // Suppress "(Not found)" for async contexts
state.activeThumbnailUrl = thumbnailUrl;
} else {
btn.removeAttribute('href');
btn.title = 'View Image (Not found)';
state.activeThumbnailUrl = null;
}
}
} else {
// Page Button
if (hostUrl) {
if (btn.getAttribute('href') !== hostUrl || policyChanged) {
btn.href = hostUrl;
btn.title = 'Visit Page';
state.hostSource = hostUrlSource; // Store for logging
}
} else {
btn.removeAttribute('href');
btn.title = 'Visit Page (Not found)';
}
}
}
/**
* Handles clicking the Image button.
* Supports both Left Click and Middle Click (auxclick).
* @param {MouseEvent} e
*/
async _handleImageClick(e) {
// Only handle Left Click (0) or Middle Click (1)
if (e.button !== 0 && e.button !== 1) return;
this._stopProp(e);
e.preventDefault(); // Always prevent default to control opening behavior
const btn = e.currentTarget;
if (!(btn instanceof HTMLAnchorElement)) return;
const state = this.btnState.get(btn);
if (!state) return;
if (state.isFetching) return;
state.isFetching = true;
// Visual feedback
btn.style.opacity = '0.6';
btn.style.cursor = 'wait';
const config = this.configManager.get();
const fetchStrategy = config.common.fetchStrategy || CONSTANTS.FETCH_STRATEGY.AUTO;
// Notify user that fetch is in progress (since we don't open a tab immediately)
this.showToast('Fetching image info...', 'info');
let targetUrl = null;
try {
this._updateButtonHref(btn);
// 1. Fetch URL (Async for DDG/Google)
if (this.originalImageFetcher) {
Logger.info('ASYNC FETCH', LOG_STYLES.PURPLE, 'Fetching original URL via adapter...');
targetUrl = await this.originalImageFetcher(state.sentinel);
if (targetUrl) {
Logger.info('ASYNC SUCCESS', LOG_STYLES.GREEN, `URL: ${targetUrl}`);
} else {
Logger.warn('ASYNC FAIL', '', 'Fetcher returned null.');
}
}
// Fallback / Default URL
if (!targetUrl) {
if (!btn.getAttribute('href')) {
await this._waitForValidUrl(btn);
}
targetUrl = btn.href;
}
if (!targetUrl || targetUrl === window.location.href) {
throw new Error('Image URL not found');
}
// Cleanup previous resources
if (state.activeRevokeTimer) {
clearTimeout(state.activeRevokeTimer);
state.activeRevokeTimer = null;
}
if (state.activeBlobUrl) {
URL.revokeObjectURL(state.activeBlobUrl);
state.activeBlobUrl = null;
}
Logger.info('CHECK START', LOG_STYLES.GRAY, targetUrl);
// 2. Determine Mode (Blob vs Direct)
let useBlob = false;
if (targetUrl.startsWith('http://')) {
// HTTP scheme detected. Forcing Direct mode to avoid Mixed Content blocks.
Logger.info('DECISION', LOG_STYLES.PURPLE, 'HTTP scheme detected. Forcing Direct mode.');
useBlob = false;
} else {
switch (fetchStrategy) {
case CONSTANTS.FETCH_STRATEGY.BLOB:
Logger.info('DECISION', LOG_STYLES.PURPLE, 'Strategy="Always Blob"');
useBlob = true;
break;
case CONSTANTS.FETCH_STRATEGY.DIRECT:
Logger.info('DECISION', LOG_STYLES.PURPLE, 'Strategy="Always Direct"');
useBlob = false;
break;
case CONSTANTS.FETCH_STRATEGY.AUTO:
default:
// Default behavior: Check headers
useBlob = await NetworkHelper.shouldFetchAsBlob(targetUrl, config.common.referrerPolicy);
break;
}
}
// 3. Execute Opening
if (useBlob) {
Logger.info('DECISION', LOG_STYLES.BLUE, `Mode="Blob", Policy="${config.common.referrerPolicy}"`);
const blob = await NetworkHelper.fetchImageAsBlob(targetUrl, config.common.referrerPolicy, config.common.retryOnFailure, CONSTANTS.LOG_TAGS.ORIGINAL);
const blobUrl = URL.createObjectURL(blob);
state.activeBlobUrl = blobUrl;
Logger.info('OPENING', LOG_STYLES.GREEN, `Opening Blob URL: "${blobUrl}"`);
// Use Anchor Tag Click for Blob URLs
// GM.openInTab often fails with blob: URLs due to security restrictions.
// We use a standard anchor click which browsers handle correctly for local resources.
const a = document.createElement('a');
a.href = blobUrl;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), CONSTANTS.TIMEOUTS.UI_DELAY);
// Schedule cleanup
const revokeTime = config.common.blobRevokeTimeout || 600000;
Logger.info('BLOB', LOG_STYLES.GRAY, `Revoke scheduled in ${revokeTime / 60000} min`);
state.activeRevokeTimer = setTimeout(() => {
URL.revokeObjectURL(blobUrl);
if (state.activeBlobUrl === blobUrl) {
state.activeBlobUrl = null;
state.activeRevokeTimer = null;
}
}, revokeTime);
} else {
Logger.info('DECISION', LOG_STYLES.GREEN, `Mode="Direct"`);
Logger.info('OPENING', LOG_STYLES.GREEN, `Opening Direct URL: "${targetUrl}"`);
if (e.button === 1) {
// Middle Click: Synthetic auxclick events do not trigger native tab navigation on anchors.
// Fallback to GM.openInTab to reliably open the link in the background.
GM.openInTab(targetUrl, { active: false, insert: false });
} else {
// Left Click or Ctrl/Meta + Click (button === 0): Use dynamic anchor to respect referrer policy settings.
const a = document.createElement('a');
a.href = targetUrl;
a.target = '_blank';
const currentPolicy = config.common.referrerPolicy;
a.setAttribute('referrerpolicy', currentPolicy);
const relValue = currentPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER ? 'noopener noreferrer' : 'noopener';
a.setAttribute('rel', relValue);
a.style.display = 'none';
document.body.appendChild(a);
// Dispatch a cloned MouseEvent to preserve background/foreground preferences for click events.
const clickEvent = new MouseEvent(e.type, {
bubbles: e.bubbles,
cancelable: e.cancelable,
view: e.view,
detail: e.detail,
screenX: e.screenX,
screenY: e.screenY,
clientX: e.clientX,
clientY: e.clientY,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
button: e.button,
buttons: e.buttons,
relatedTarget: e.relatedTarget,
});
a.dispatchEvent(clickEvent);
setTimeout(() => a.remove(), CONSTANTS.TIMEOUTS.UI_DELAY);
}
}
} catch (err) {
// Error handling (Thumbnail fallback etc.)
if (state.activeThumbnailUrl) {
this.showToast('Original image failed. Opening preview...', 'warn');
Logger.warn('FALLBACK', '', `Original failed: ${err.message}`);
try {
const blob = await NetworkHelper.fetchImageAsBlob(state.activeThumbnailUrl, config.common.referrerPolicy, false, CONSTANTS.LOG_TAGS.THUMBNAIL);
const blobUrl = URL.createObjectURL(blob);
state.activeBlobUrl = blobUrl;
// Use anchor for fallback blob as well
const a = document.createElement('a');
a.href = blobUrl;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), CONSTANTS.TIMEOUTS.UI_DELAY);
this.showToast('Preview image loaded (Original unavailable).', 'info');
// Cleanup logic...
const revokeTime = config.common.blobRevokeTimeout || 600000;
state.activeRevokeTimer = setTimeout(() => {
URL.revokeObjectURL(blobUrl);
if (state.activeBlobUrl === blobUrl) {
state.activeBlobUrl = null;
state.activeRevokeTimer = null;
}
}, revokeTime);
return;
} catch (e) {
/* ignore thumbnail fail */
}
}
// Final Fallback: Direct Link
const fallbackUrl = targetUrl || (btn.href && btn.href !== window.location.href ? btn.href : null);
if (fallbackUrl) {
Logger.warn('FALLBACK', '', `Reverting to direct navigation.`);
if (e.button === 1) {
// Middle Click fallback
GM.openInTab(fallbackUrl, { active: false, insert: false });
} else {
// Left Click or Ctrl/Meta + Click fallback
const a = document.createElement('a');
a.href = fallbackUrl;
a.target = '_blank';
const currentPolicy = config.common.referrerPolicy;
a.setAttribute('referrerpolicy', currentPolicy);
const relValue = currentPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER ? 'noopener noreferrer' : 'noopener';
a.setAttribute('rel', relValue);
a.style.display = 'none';
document.body.appendChild(a);
const clickEvent = new MouseEvent(e.type, {
bubbles: e.bubbles,
cancelable: e.cancelable,
view: e.view,
detail: e.detail,
screenX: e.screenX,
screenY: e.screenY,
clientX: e.clientX,
clientY: e.clientY,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
button: e.button,
buttons: e.buttons,
relatedTarget: e.relatedTarget,
});
a.dispatchEvent(clickEvent);
setTimeout(() => a.remove(), CONSTANTS.TIMEOUTS.UI_DELAY);
}
this.showToast('Fetch failed. Opening direct link...', 'warn');
} else {
this.showToast('Image URL not found', 'error');
}
} finally {
state.isFetching = false;
btn.style.opacity = '';
btn.style.cursor = '';
}
}
/**
* Handles clicking the Page button.
* Supports both Left Click and Middle Click (auxclick).
* @param {MouseEvent} e
*/
_handlePageClick(e) {
// Only handle Left Click (0) or Middle Click (1)
if (e.button !== 0 && e.button !== 1) return;
this._stopProp(e);
const btn = e.currentTarget;
if (!(btn instanceof HTMLAnchorElement)) return;
// Ensure URL is up-to-date.
this._updateButtonHref(btn);
if (btn.href && btn.href !== window.location.href) {
const state = this.btnState.get(btn);
const source = state ? state.hostSource : 'UNKNOWN';
Logger.info('NAVIGATING', LOG_STYLES.GREEN, `Host URL: "${btn.href}" (Source: ${source})`);
// For Page Button, we just let the browser handle the link navigation normally.
// Middle click will open in new tab (default behavior).
// Left click will open in new tab (target="_blank").
} else {
e.preventDefault();
this.showToast('Host page URL not found.', 'warn');
}
}
}
// =================================================================================
// SECTION: Data & Logic Adapters
// =================================================================================
/**
* @class NetworkHelper
* @description Handles network requests and binary data processing.
*/
class NetworkHelper {
/**
* Determines if the URL should be fetched as a Blob or opened directly.
* Uses a HEAD request to check for forced download headers.
* @param {string} url
* @param {string} referrerPolicy - The referrer policy to use for the request.
* @returns {Promise<boolean>} True if Blob fetch is recommended, False if direct navigation is safe.
*/
static async shouldFetchAsBlob(url, referrerPolicy) {
return new Promise((resolve) => {
GM.xmlHttpRequest({
method: 'HEAD',
url: url,
timeout: CONSTANTS.NETWORK_TIMEOUT,
headers: this._getHeaders(referrerPolicy),
onload: (response) => {
// If HEAD fails (e.g. 405 Method Not Allowed), default to Blob strategy to be safe.
if (response.status < 200 || response.status >= 300) {
resolve(true);
return;
}
const headers = (response.responseHeaders || '').toLowerCase();
// Parse Content-Disposition to check for 'attachment'
// regex matches: content-disposition: ... attachment ...
if (/content-disposition:.*attachment/.test(headers)) {
resolve(true);
return;
}
// Parse Content-Type
const typeMatch = headers.match(/content-type:\s*([^;\r\n]+)/);
const contentType = typeMatch ? typeMatch[1].trim() : '';
// If it's explicitly an image, safe to open directly.
// Otherwise (octet-stream, unknown, etc.), use Blob.
if (contentType.startsWith('image/')) {
resolve(false);
} else {
resolve(true);
}
},
onerror: () => {
// Network error on HEAD. Try Blob flow (GET) as it might have better error handling or succeed.
resolve(true);
},
ontimeout: () => resolve(true),
});
});
}
/**
* Fetches an image URL and returns it as a Blob.
* Detects the correct MIME type from binary headers to prevent forced downloads.
* Implements automatic retry logic if shouldRetry is true.
* Handles Data URIs directly without network requests.
* @param {string} url - The image URL to fetch.
* @param {string} referrerPolicy - The referrer policy to use for the request.
* @param {boolean} shouldRetry - Whether to attempt one retry with a different policy on failure.
* @param {string} logTag - Tag for logging purposes (e.g., 'ORIGINAL', 'THUMBNAIL').
* @returns {Promise<Blob>} The image data as a Blob.
*/
static async fetchImageAsBlob(url, referrerPolicy, shouldRetry, logTag) {
// 1. Handle Data URI Scheme directly
if (url.startsWith('data:')) {
Logger.info(`FETCH (${logTag})`, LOG_STYLES.BLUE, `Processing Data URI...`);
try {
const blob = this._base64ToBlob(url);
Logger.info(`FETCH OK (${logTag})`, LOG_STYLES.GREEN, `Type="${blob.type}", Size=${(blob.size / 1024).toFixed(2)}KB`);
return blob;
} catch (error) {
Logger.error(`FETCH ERROR (${logTag})`, '', `Data URI parsing failed: ${error.message}`);
throw error;
}
}
// 2. Handle HTTP/HTTPS URLs
Logger.info(`FETCH (${logTag})`, LOG_STYLES.BLUE, `Executing... Policy="${referrerPolicy}"`);
const attemptFetch = (policy) => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: url,
timeout: CONSTANTS.NETWORK_TIMEOUT,
responseType: 'arraybuffer',
headers: this._getHeaders(policy),
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
const buffer = response.response;
// Detect MIME type from binary signature
let mimeType = this._detectMimeType(buffer);
// Fallback: Check Content-Type header if magic bytes detection failed
// Only accept specific types (e.g., SVG) to avoid processing HTML as image
if (!mimeType) {
const headers = (response.responseHeaders || '').toLowerCase();
const typeMatch = headers.match(/content-type:\s*([^;\r\n]+)/);
const contentType = typeMatch ? typeMatch[1].trim() : '';
if (contentType === 'image/svg+xml') {
mimeType = contentType;
}
}
if (mimeType) {
Logger.info(`FETCH OK (${logTag})`, LOG_STYLES.GREEN, `Type="${mimeType}", Size=${(buffer.byteLength / 1024).toFixed(2)}KB`);
const blob = new Blob([buffer], { type: mimeType });
resolve(blob);
} else {
// Reject non-image data (HTML, Video, etc.) to trigger fallback
reject(new Error('Unsupported file type'));
}
} else {
reject(new Error(`HTTP error ${response.status}`));
}
},
onerror: (err) => {
reject(new Error('Network request failed'));
},
ontimeout: () => {
reject(new Error('Request timed out'));
},
});
});
};
try {
return await attemptFetch(referrerPolicy);
} catch (error) {
Logger.error(`FETCH ERROR (${logTag})`, '', `Policy="${referrerPolicy}", Reason="${error.message}"`);
if (shouldRetry) {
// Determine alternative policy based on system-defined strategy
let nextPolicy = CONSTANTS.REFERRER_POLICY.ORIGIN; // Default fallback
if (referrerPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER) {
nextPolicy = CONSTANTS.REFERRER_POLICY.ORIGIN; // If hidden failed, try showing origin
} else if (referrerPolicy === CONSTANTS.REFERRER_POLICY.ORIGIN || referrerPolicy === CONSTANTS.REFERRER_POLICY.UNSAFE_URL) {
nextPolicy = CONSTANTS.REFERRER_POLICY.NO_REFERRER; // If origin/unsafe failed, try hiding
}
// Prevent redundant retry if nextPolicy is same as current (edge case)
if (nextPolicy !== referrerPolicy) {
Logger.warn('RETRY', '', `Switching "${referrerPolicy}" -> "${nextPolicy}"`);
// Explicitly pass false to ensure max 1 retry
return await this.fetchImageAsBlob(url, nextPolicy, false, logTag);
}
}
throw error;
}
}
/**
* Converts a Base64 Data URI string to a Blob object.
* @private
* @param {string} dataUrl - The Data URI string (e.g., "data:image/jpeg;base64,...").
* @returns {Blob} The created Blob object.
*/
static _base64ToBlob(dataUrl) {
if (!dataUrl.startsWith('data:')) {
throw new Error('Invalid Data URI: Missing "data:" prefix');
}
const commaIndex = dataUrl.indexOf(',');
if (commaIndex === -1) {
throw new Error('Invalid Data URI: Missing comma separator');
}
const metadata = dataUrl.slice(0, commaIndex);
const data = dataUrl.slice(commaIndex + 1);
// Parse metadata: data:[<mediatype>][;base64]
const mimeMatch = metadata.match(/:(.*?)(;|$)/);
const mime = mimeMatch ? mimeMatch[1] : 'text/plain';
const isBase64 = metadata.includes(';base64');
if (isBase64) {
try {
const bstr = atob(data);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
} catch (e) {
throw new Error(`Base64 decoding failed: ${e.message}`);
}
} else {
// Non-base64 (URL-encoded) data is not fully implemented yet
// Throw error to trigger fallback mechanism
throw new Error('Unsupported Data URI encoding: Non-base64');
}
}
/**
* Generates headers for GM.xmlHttpRequest based on the referrer policy.
* @private
* @param {string} policy
* @returns {object} Headers object
*/
static _getHeaders(policy) {
const headers = {};
switch (policy) {
case CONSTANTS.REFERRER_POLICY.NO_REFERRER:
// Explicitly set empty string to suppress referrer
headers['Referer'] = '';
break;
case CONSTANTS.REFERRER_POLICY.UNSAFE_URL:
headers['Referer'] = window.location.href;
break;
case CONSTANTS.REFERRER_POLICY.ORIGIN:
default:
headers['Referer'] = window.location.origin;
break;
}
return headers;
}
/**
* Detects MIME type from the first few bytes (Magic Numbers).
* @private
* @param {ArrayBuffer} buffer
* @returns {string|null} Detected MIME type or null.
*/
static _detectMimeType(buffer) {
if (!buffer || buffer.byteLength < 4) return null;
const arr = new Uint8Array(buffer).subarray(0, 12);
// Convert bytes to hex string for easy comparison
const header = Array.from(arr)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
// JPEG: FF D8 FF
if (header.startsWith('FFD8FF')) return 'image/jpeg';
// PNG: 89 50 4E 47
if (header.startsWith('89504E47')) return 'image/png';
// GIF: 47 49 46 38
if (header.startsWith('47494638')) return 'image/gif';
// WebP: RIFF....WEBP (RIFF at 0, WEBP at 8)
// 'RIFF' in hex is 52 49 46 46, 'WEBP' is 57 45 42 50
if (header.startsWith('52494646') && header.slice(16, 24) === '57454250') return 'image/webp';
// BMP: 42 4D
if (header.startsWith('424D')) return 'image/bmp';
// ICO: 00 00 01 00
if (header.startsWith('00000100')) return 'image/x-icon';
// AVIF: ....ftypavif (ftyp at offset 4, avif at offset 8)
// Offset 4-7 (ftyp): 66 74 79 70 -> Index 8-16
// Offset 8-11 (avif): 61 76 69 66 -> Index 16-24
if (header.slice(8, 16) === '66747970' && header.slice(16, 24) === '61766966') return 'image/avif';
return null;
}
}
/**
* @class BaseAdapter
* @abstract
* @description Base class for platform-specific adapters.
*/
class BaseAdapter extends BaseManager {
/**
* @param {UIManager} uiManager
*/
constructor(uiManager) {
super();
this.uiManager = uiManager;
/** @type {boolean} */
this.hasSmokeTested = false;
}
/**
* Unique identifier for the platform.
* @returns {string}
*/
static get id() {
return 'base';
}
/**
* Checks if this adapter should run on the current page.
* @returns {boolean}
*/
static isApplicable() {
return false;
}
/**
* Returns the CSS selector for the sentinel element.
* @returns {string|null}
*/
getSentinelSelector() {
return null;
}
/**
* Called when a new result element is detected.
* @param {HTMLElement} element
*/
onResultFound(element) {
// To be implemented by subclasses
}
/**
* Extracts the high-resolution image URL and the host page URL from the result element.
* @param {HTMLElement} element - The sentinel element.
* @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
* @throws {Error} Must be implemented by subclasses.
*/
extractUrls(element) {
throw new Error('extractUrls() must be implemented by subclasses.');
}
/**
* Asynchronously fetches the original image URL by interacting with the DOM.
* Used when the high-res URL is not present in the initial DOM (e.g., DuckDuckGo).
* @param {HTMLElement} element - The sentinel element.
* @returns {Promise<string|null>} The original image URL or null if not found.
*/
async fetchOriginalImageUrl(element) {
return null;
}
}
/**
* @class BingAdapter
* @extends BaseAdapter
* @description Adapter for Bing Image Search.
* Handles DOM interactions specific to Bing's search results page.
*/
class BingAdapter extends BaseAdapter {
constructor(uiManager) {
super(uiManager);
this.uiManager.setUrlFetcher((element) => this.extractUrls(element));
}
static get id() {
return 'bing';
}
/**
* Checks if the current page is a supported Bing Image Search page.
* @returns {boolean}
*/
static isApplicable() {
// prettier-ignore
return (
// Match *.bing.com (e.g., www, cn, global) to align with @match
/(^|\.)bing\.com$/.test(window.location.hostname) &&
(window.location.pathname.startsWith('/images/search') || window.location.pathname.startsWith('/images/vnext/search'))
);
}
/**
* Returns the CSS selector for the sentinel element.
* In Bing, `a.iusc` (Image URL Source Container?) is the interactive element holding metadata.
* @returns {string}
*/
getSentinelSelector() {
return 'a.iusc';
}
/**
* Called when a new result element is detected.
* Attaches buttons to the parent container (usually `div.img_cont`) to overlay correctly on the image.
* @param {HTMLElement} element - The detected `a.iusc` element.
*/
onResultFound(element) {
const targetContainer = element.parentElement;
if (targetContainer) {
this.uiManager.attachButtons(element, targetContainer);
}
}
/**
* Extracts the high-resolution image URL and the host page URL from Bing's metadata.
*
* Extraction Strategy:
* Bing stores metadata as a JSON string in the `m` attribute of the `a.iusc` element.
*
* JSON Keys:
* - `murl`: Media URL (The direct link to the high-res image).
* - `purl`: Page URL (The link to the website hosting the image).
* - `turl`: Thumbnail URL (Not used here, but available).
*
* @param {HTMLElement} element - The `a.iusc` element containing the `m` attribute.
* @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
*/
extractUrls(element) {
let imageUrl = null;
let hostUrl = null;
let hostUrlSource = null;
let thumbnailUrl = null;
try {
// Bing stores metadata in the 'm' attribute as a JSON string.
// Example: m='{"murl":"...","purl":"...","turl":"..."}'
const mAttr = element.getAttribute('m');
if (mAttr) {
const data = JSON.parse(mAttr);
// 'murl': Media URL (Direct link to the high-res image)
if (data.murl) {
imageUrl = data.murl;
} else {
Logger.error('EXTRACTION_FAIL', '', 'Bing: "murl" missing in metadata.', data);
}
// 'purl': Page URL (Link to the hosting webpage)
if (data.purl) {
hostUrl = data.purl;
hostUrlSource = 'M-ATTR';
}
// 'turl': Thumbnail URL (Fallback)
if (data.turl) {
thumbnailUrl = data.turl;
}
} else {
Logger.error('EXTRACTION_FAIL', '', 'Bing: "m" attribute missing on sentinel.');
}
} catch (e) {
Logger.error('EXTRACTION_FAIL', '', 'Bing: JSON parse error or structure change.', e);
// JSON parse error or structure change.
// We silently fail here as other elements might not have valid JSON.
}
return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
}
}
/**
* @class DuckDuckGoAdapter
* @extends BaseAdapter
* @description Adapter for DuckDuckGo Image Search.
*/
class DuckDuckGoAdapter extends BaseAdapter {
constructor(uiManager) {
super(uiManager);
this.uiManager.setUrlFetcher((element) => this.extractUrls(element));
}
static get id() {
return 'duckduckgo';
}
static isApplicable() {
if (!/(^|\.)duckduckgo\.com$/.test(window.location.hostname)) return false;
const params = new URLSearchParams(window.location.search);
// Check for any parameter starting with 'ia' (e.g., ia, iax, iar) with value 'images'
for (const [key, value] of params.entries()) {
if (key.startsWith('ia') && value === 'images') {
return true;
}
}
return false;
}
/**
* Returns the CSS selector for the sentinel element.
* Updated to use tag name 'figure' as DDG now uses obfuscated class names.
* @returns {string}
*/
getSentinelSelector() {
// Target 'figure' elements which are the containers for image cards in the new React layout
// Use :not() selector to re-trigger Sentinel if the processed class is removed by the host
return `figure:not(.${CONSTANTS.PROCESSED_CLASS})`;
}
/**
* Called when a new result element is detected.
* @param {HTMLElement} element - The detected sentinel element.
*/
onResultFound(element) {
// Verify if this figure contains the expected image search structure
// It should have an anchor tag and an image
if (element.querySelector('a') && element.querySelector('img')) {
this.uiManager.attachButtons(element, element);
}
}
/**
* Extracts URLs from DuckDuckGo result element.
*
* [LIMITATION & DESIGN DECISION]
* 1. Cached URL Only: The URL obtained here is typically a cached version (via Bing/DDG proxy), NOT the direct original source URL.
* 2. DOM Limitation: The true high-resolution original URL is NOT present in the card's DOM. It is only injected after clicking the card to open the detail panel.
* 3. Strategy: We intentionally use this cached URL (extracted from the 'u' param) to enable immediate access from the grid view.
* This accepts a trade-off: slightly lower resolution in exchange for significantly better UX (0-click access).
*
* @param {HTMLElement} element - The sentinel element (figure).
* @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
*/
extractUrls(element) {
let imageUrl = null;
let hostUrl = null;
let hostUrlSource = null;
let thumbnailUrl = null;
// 1. Get Host URL from the anchor tag
const link = element.querySelector('a');
if (link && link.href) {
// STRICTLY use the href as is.
// Do NOT decode or strip any redirect parameters (e.g. duckduckgo.com/l/?uddg=...).
// We must respect DDG's privacy protections (redirects/referrer hiding) if present.
hostUrl = link.href;
hostUrlSource = 'HREF';
} else {
// Validation: If it looks like an image card (has img) but no link, it's a structure error.
if (element.querySelector('img')) {
Logger.error('EXTRACTION_FAIL', '', 'DDG: Host URL (anchor) missing in image card.');
}
}
// 2. Get Image URL & Thumbnail URL
const img = element.querySelector('img');
if (img && img.src) {
// Use the proxy URL as the thumbnail
thumbnailUrl = img.src;
// Extract the cached image URL from the 'u' parameter of the proxy URL.
// NOTE: This is NOT the original source URL but a cached version used by DDG/Bing.
// We use this because the original URL is not available in the card view DOM.
try {
const urlObj = new URL(img.src);
const originalParam = urlObj.searchParams.get('u');
if (originalParam) {
imageUrl = decodeURIComponent(originalParam);
} else {
// Fallback: Use proxy URL if 'u' parameter is missing
imageUrl = img.src;
}
} catch (e) {
// Fallback: Use proxy URL on parse error
imageUrl = img.src;
}
}
return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
}
/**
* Asynchronously fetches the original image URL by opening the detail panel.
* Verifies the panel content matches the clicked item using the Page URL.
* Scopes extraction to the visible container to avoid grabbing preloaded/hidden links.
* @param {HTMLElement} element - The sentinel element.
* @returns {Promise<string|null>} The original image URL or null.
*/
async fetchOriginalImageUrl(element) {
return new Promise((resolve) => {
// 1. Get the expected Page URL to verify the panel content later
const anchor = element.querySelector('a');
if (!anchor || !anchor.href) {
Logger.warn('ASYNC_FAIL', '', 'DDG: Trigger anchor missing.');
resolve(null);
return;
}
const expectedPageUrl = anchor.href;
// Helper to normalize URL for loose comparison (remove trailing slash)
const normalizeUrl = (u) => (u ? u.replace(/\/$/, '') : '');
// 2. Trigger click to open detail panel
// Click the image (img) to open panel without navigation.
const trigger = element.querySelector('img');
if (!trigger) {
Logger.warn('ASYNC_FAIL', '', 'DDG: Trigger image missing.');
resolve(null);
return;
}
// Smart Scroll Clamping & Stealth Mode
// 1. Clamp: Locks scroll position to prevent jumping.
// 2. Stealth: Hides the detail panel (aside) via direct style injection to the specific element.
const savedScrollY = window.scrollY;
let isClamping = true;
const userEvents = ['wheel', 'touchmove', 'keydown', 'mousedown'];
// Handler to break the clamp on user interaction
const stopClamping = () => {
isClamping = false;
};
// Attach listeners to detect user intent (capture phase)
// Use explicit boolean 'true' for capture to ensure removeEventListener works reliably across browsers.
userEvents.forEach((evt) => window.addEventListener(evt, stopClamping, true));
// Stealth Logic: Use MutationObserver to hide the specific panel as soon as it appears.
let targetAside = null;
const originalStyles = { opacity: '', pointerEvents: '' };
let pollTimeoutId = null; // Holds the polling timeout ID
const hideElement = (el) => {
if (el && el.style && !targetAside) {
// Backup original inline styles to allow safe restoration
originalStyles.opacity = el.style.opacity;
originalStyles.pointerEvents = el.style.pointerEvents;
el.style.setProperty('opacity', '0', 'important');
el.style.setProperty('pointer-events', 'none', 'important');
targetAside = el; // Keep reference for cleanup
// Optimization: Disconnect observer once the target is found and hidden
observer.disconnect();
}
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'ASIDE') {
hideElement(node);
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Start clamping loop
const start = Date.now();
const clampDuration = CONSTANTS.TIMEOUTS.SCROLL_CLAMP;
const maintainScroll = () => {
if (isClamping) {
window.scrollTo(0, savedScrollY);
if (Date.now() - start < clampDuration) {
requestAnimationFrame(maintainScroll);
}
}
};
requestAnimationFrame(maintainScroll);
let isResolved = false;
// Helper to clean up listeners, styles, and ensure final state
const finalize = () => {
if (pollTimeoutId) {
clearTimeout(pollTimeoutId);
pollTimeoutId = null;
}
// Stop observing (if not already stopped)
observer.disconnect();
// Cleanup styles if the panel still exists (restore visibility safely)
if (targetAside) {
if (originalStyles.opacity) {
targetAside.style.opacity = originalStyles.opacity;
} else {
targetAside.style.removeProperty('opacity');
}
if (originalStyles.pointerEvents) {
targetAside.style.pointerEvents = originalStyles.pointerEvents;
} else {
targetAside.style.removeProperty('pointer-events');
}
}
// Stop clamping
isClamping = false;
userEvents.forEach((evt) => window.removeEventListener(evt, stopClamping, true));
// Final position restoration after layout settles
window.scrollTo(0, savedScrollY);
setTimeout(() => window.scrollTo(0, savedScrollY), CONSTANTS.TIMEOUTS.UI_DELAY);
};
// Manage active fetch instance via resource manager
this.manageResource(CONSTANTS.RESOURCE_KEYS.ACTIVE_FETCH, () => {
// Guard against multiple resolutions when manager is destroyed
if (!isResolved) {
isResolved = true;
finalize();
resolve(null);
}
});
// Use standard click() to prevent 'MouseEvent constructor' error
trigger.click();
// 3. Wait for the detail panel (<aside>) to appear AND match the expected URL
const MAX_ATTEMPTS = Math.ceil(CONSTANTS.TIMEOUTS.FETCH_ORIGINAL / CONSTANTS.TIMEOUTS.DOM_POLLING);
let attempts = 0;
const checkPanel = () => {
attempts++;
const aside = document.querySelector('aside');
if (aside) {
// Fail-safe: Ensure style is applied if Observer missed it (e.g. reused DOM)
if (!targetAside) {
hideElement(aside);
}
// Target ONLY the visible container within aside to avoid hidden/preloaded slides.
// DDG uses aria-hidden="false" for the active slide.
// Fallback to aside itself if structure changes/not found (though unlikely).
const activeContainer = aside.querySelector('[aria-hidden="false"]') || aside;
// VERIFICATION: Check if this visible container belongs to the clicked image.
const linksInPanel = activeContainer.querySelectorAll('a');
let isMatch = false;
const targetUrlNorm = normalizeUrl(expectedPageUrl);
for (const link of linksInPanel) {
if (normalizeUrl(link.href) === targetUrlNorm) {
isMatch = true;
break;
}
}
if (isMatch) {
// Found the correct panel content. Proceed to extract from THIS container.
processPanel(aside, activeContainer);
return;
}
}
if (attempts >= MAX_ATTEMPTS) {
Logger.warn('ASYNC_FAIL', '', 'DDG: Panel detection timed out.');
if (!isResolved) {
isResolved = true;
finalize();
this.manageResource(CONSTANTS.RESOURCE_KEYS.ACTIVE_FETCH, null);
resolve(null);
}
} else {
pollTimeoutId = setTimeout(checkPanel, CONSTANTS.TIMEOUTS.DOM_POLLING);
}
};
const processPanel = (aside, container) => {
let foundUrl = null;
// Strategy: Find 'a' tags with target="_blank" inside the ACTIVE container
const links = container.querySelectorAll('a[target="_blank"]');
// Priority 1: Link ending with common image extensions
for (const link of links) {
const href = link.href;
if (/\.(jpg|jpeg|png|webp|gif|bmp|svg)(\?.*)?$/i.test(href)) {
foundUrl = href;
break;
}
}
// Priority 2: Fallback to the last external link found in the active container
if (!foundUrl && links.length > 0) {
foundUrl = links[links.length - 1].href;
}
if (!foundUrl) {
Logger.warn('ASYNC_FAIL', '', 'DDG: Target image URL not found in panel.');
}
// 4. Close the panel immediately
// Step 1: Try using the class name approach
let closeBtn = aside.querySelector('.ddgsi-close');
if (closeBtn) {
closeBtn = closeBtn.closest('button') || closeBtn;
}
// Step 2: Fallback to the last button inside the aside element if class name approach fails
if (!closeBtn) {
const buttons = aside.querySelectorAll('button');
if (buttons.length > 0) {
closeBtn = buttons[buttons.length - 1];
}
}
// Step 3: Click the resolved close button or fallback to sending the Escape key
if (closeBtn) {
closeBtn.click();
} else {
// Fallback: Send Escape key
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
if (!isResolved) {
isResolved = true;
finalize();
this.manageResource(CONSTANTS.RESOURCE_KEYS.ACTIVE_FETCH, null);
resolve(foundUrl);
}
};
// Start polling
checkPanel();
});
}
}
/**
* @class GoogleAdapter
* @extends BaseAdapter
* @description Adapter for Google Image Search.
* Handles DOM interactions specific to Google's search results page.
*/
class GoogleAdapter extends BaseAdapter {
constructor(uiManager) {
super(uiManager);
this.uiManager.setUrlFetcher((element) => this.extractUrls(element));
// Stores recent /imgres metadata captured on thumbnail click
this._lastImgresData = null;
this._lastThumbClickAt = 0;
this._lastLoggedPanelUrl = null;
this._lastLoggedPanelMode = null;
this._setupPanelSupport();
}
static get id() {
return 'google';
}
/**
* Checks if the current page is a supported Google Image Search page.
* Supported contexts:
* 1. Standard Web Search with "Images" tab selected (`udm=2`).
* 2. Dedicated Image Search page (`tbm=isch`).
* @returns {boolean}
*/
static isApplicable() {
// prettier-ignore
return (
// Match *.google.com to align with @match
/(^|\.)google\.com$/.test(window.location.hostname) &&
(
// 'udm=2': Indicates the "Images" tab in standard Web Search (new interface)
new URL(window.location.href).searchParams.get('udm') === '2' ||
// 'tbm=isch': Indicates the dedicated Image Search mode (classic interface)
new URL(window.location.href).searchParams.get('tbm') === 'isch'
)
);
}
/**
* Sets up event listeners to capture /imgres data when thumbnails are clicked.
* @private
*/
_setupPanelSupport() {
const onThumbClick = (e) => {
const target = e.target;
const link = target && typeof target.closest === 'function' ? target.closest('a[href*="/imgres"]') : null;
if (link) {
const data = this._parseImgresLink(link.href);
if (data) {
const img = link.querySelector('img');
data.thumbnailUrl = img ? img.getAttribute('data-src') || img.getAttribute('src') : null;
this._lastImgresData = data;
}
}
this._lastThumbClickAt = Date.now();
};
document.addEventListener('click', onThumbClick, true);
this.addDisposable(() => document.removeEventListener('click', onThumbClick, true));
}
/**
* Parses an /imgres link URL into imageUrl and hostUrl.
* @private
* @param {string} href
* @returns {{imageUrl: string|null, hostUrl: string|null, thumbnailUrl: string|null}|null}
*/
_parseImgresLink(href) {
try {
const params = new URLSearchParams(new URL(href).search);
const imageUrl = params.get('imgurl');
const hostUrl = params.get('imgrefurl');
if (!imageUrl && !hostUrl) return null;
return { imageUrl, hostUrl, thumbnailUrl: null };
} catch (e) {
return null;
}
}
/**
* Returns the CSS selector for the sentinel element.
* Targets grid items (/imgres, data-docid) as well as preview panel main image links.
* @returns {string}
*/
getSentinelSelector() {
return 'a[href*="/imgres"], div[data-lpage][data-docid], [data-viewer-type] a[role="link"], [data-lhcontainer] a[role="link"], div[data-sci][aria-hidden="false"] a[role="link"]';
}
/**
* Called when a new result element is detected.
* Finds the anchor tag and container, while allowing main preview image links and related cards inside the panel.
* @param {HTMLElement} element - The detected sentinel element.
*/
onResultFound(element) {
const panel = element.closest('[data-lhcontainer], [data-viewer-type], div[data-sci][aria-hidden="false"]');
// Identify if the element is the main preview image link or a related image card inside the panel
const isMainPreview = panel && element.tagName === 'A' && element.getAttribute('role') === 'link' && !element.href.includes('/imgres') && !!element.querySelector('img');
const isRelatedImage = panel && !isMainPreview && (element.matches('a[href*="/imgres"]') || element.matches('div[data-lpage][data-docid]'));
// Skip elements inside the panel that are neither main preview nor related cards
if (panel && !isMainPreview && !isRelatedImage) {
return;
}
// Support both old (div) and new (a) sentinel structures
let link = element;
if (element.tagName !== 'A') {
link = element.querySelector('a');
}
if (!link) return;
// Attach buttons to the parent of the link to ensure they overlay the image correctly.
const targetContainer = link.parentElement;
if (targetContainer) {
this.uiManager.attachButtons(element, targetContainer);
}
}
/**
* Extracts URLs from a Google result element or preview panel image.
*
* Strategy:
* 1. **Preview Panel Image**: Uses recent /imgres params if available (logged in GREEN), otherwise falls back to DOM img src (logged in ORANGE).
* 2. **Grid Image URL**: Checks for 'imgurl' parameter in /imgres links (New Structure) or returns null (Old Structure).
* 3. **Host URL**: Extracted from 'imgrefurl' parameter or 'data-lpage' attribute.
* 4. **Thumbnail URL**: Extracted from the `img` tag inside the card.
*
* @param {HTMLElement} element - The sentinel element.
* @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
*/
extractUrls(element) {
let imageUrl = null;
let hostUrl = null;
let hostUrlSource = null;
let thumbnailUrl = null;
// Handle extraction for the main preview image inside the detailed panel
const panel = element.closest('[data-lhcontainer], [data-viewer-type], div[data-sci][aria-hidden="false"]');
const isMainPreview = panel && element.tagName === 'A' && element.getAttribute('role') === 'link' && !element.href.includes('/imgres');
if (isMainPreview) {
const captured = this._lastImgresData;
if (captured && captured.imageUrl) {
// Primary Path: Extracted from /imgres params
imageUrl = captured.imageUrl;
hostUrl = captured.hostUrl || element.href;
hostUrlSource = 'IMGRES-PARAM';
thumbnailUrl = captured.thumbnailUrl;
// Log only when URL or mode changes to prevent hover spam
if (this._lastLoggedPanelUrl !== imageUrl || this._lastLoggedPanelMode !== 'IMGRES') {
this._lastLoggedPanelUrl = imageUrl;
this._lastLoggedPanelMode = 'IMGRES';
Logger.info('PANEL URL [IMGRES]', LOG_STYLES.GREEN, `Image: ${imageUrl}`);
}
} else {
// Fallback Path: Directly extracted from DOM img element
hostUrl = element.href;
hostUrlSource = 'PREVIEW-LINK';
const imgEl = element.querySelector('img');
if (imgEl) {
const src = imgEl.getAttribute('data-src') || imgEl.getAttribute('src');
if (src && !/encrypted-tbn|gstatic\.com|favicon/.test(src)) {
imageUrl = src;
}
thumbnailUrl = src;
}
// Log only when URL or mode changes to prevent hover spam
if (this._lastLoggedPanelUrl !== imageUrl || this._lastLoggedPanelMode !== 'FALLBACK') {
this._lastLoggedPanelUrl = imageUrl;
this._lastLoggedPanelMode = 'FALLBACK';
Logger.warn('PANEL URL [FALLBACK]', LOG_STYLES.ORANGE, `Fallback Image: ${imageUrl || '(none)'}`);
}
}
return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
}
// Try extraction from /imgres URL parameters (New Structure: 2026-02-08)
let linkHref = null;
if (element.tagName === 'A') {
linkHref = element.href;
} else {
const link = element.querySelector('a');
if (link) linkHref = link.href;
}
if (linkHref && linkHref.includes('/imgres')) {
try {
const urlObj = new URL(linkHref);
const params = new URLSearchParams(urlObj.search);
if (params.has('imgurl')) {
imageUrl = params.get('imgurl');
}
if (params.has('imgrefurl')) {
hostUrl = params.get('imgrefurl');
hostUrlSource = 'URL-PARAM';
}
} catch (e) {
// Ignore parse errors
}
}
// 1. Extract Host Page URL from 'data-lpage' attribute (Fallback for Old Structure)
if (!hostUrl && element.dataset.lpage) {
hostUrl = element.dataset.lpage;
hostUrlSource = 'DATA-ATTR';
} else if (!hostUrl && !imageUrl) {
// Log only if both strategies failed
Logger.error('EXTRACTION_FAIL', '', 'Google: Host URL extraction failed.');
}
// 2. Extract Thumbnail URL
const imgEl = element.querySelector('img');
if (imgEl) {
thumbnailUrl = imgEl.getAttribute('data-src') || imgEl.getAttribute('src');
}
// Image URL is intentionally null to trigger async fetch (if not found in params)
return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
}
/**
* Asynchronously fetches the original image URL by interacting with the detailed panel.
*
* Flow:
* 0. [Preview Panel] Returns the extracted imageUrl immediately for main preview elements.
* 1. [New Structure] Checks for /imgres links and extracts URL params immediately.
* 2. [Old Structure] Checks for 'data-docid' and triggers side panel interaction.
* 3. [Fallback] Returns null if neither structure is matched.
*
* @param {HTMLElement} element - The sentinel element.
* @returns {Promise<string|null>} The original image URL or null.
*/
async fetchOriginalImageUrl(element) {
// If the element is the main preview link inside panel, return its image URL immediately
const panel = element.closest('[data-lhcontainer], [data-viewer-type], div[data-sci][aria-hidden="false"]');
const isMainPreview = panel && element.tagName === 'A' && element.getAttribute('role') === 'link' && !element.href.includes('/imgres');
if (isMainPreview) {
const urls = this.extractUrls(element);
return urls.imageUrl;
}
// 1. New Structure Strategy: Anchor tag with /imgres
if (element.tagName === 'A' && element.href.includes('/imgres')) {
try {
const params = new URLSearchParams(new URL(element.href).search);
return params.get('imgurl');
} catch (e) {
return null;
}
}
// 2. Old Structure Strategy: Element with data-docid
// If the element does NOT have a docid, it's not a valid old-structure sentinel.
// We return null immediately to avoid false positive warnings.
if (!element.dataset.docid) {
return null;
}
// At this point, we are certain it's an old-structure element.
// Proceed with the expensive async operation.
return new Promise((resolve) => {
const docId = element.dataset.docid;
const lpage = element.dataset.lpage;
// Identify the trigger button (div[role="button"]) inside the sentinel
const trigger = element.querySelector('div[role="button"]');
if (!trigger) {
Logger.warn('ASYNC_FAIL', '', 'Google: Trigger button missing.');
resolve(null);
return;
}
// Inject Stealth Styles (Hide the detailed panel container by data-id)
const stealthStyle = document.createElement('style');
const nonce = document.querySelector('script[nonce]')?.nonce;
if (nonce) stealthStyle.setAttribute('nonce', nonce);
stealthStyle.textContent = `
div[data-id="${docId}"] {
opacity: 0 !important;
pointer-events: none !important;
}
`;
document.head.appendChild(stealthStyle);
let observer = null;
let timeoutId = null;
let lastKnownSrc = null; // Keep track of the best available URL (including Base64/Thumbnail)
let isResolved = false;
const finalize = () => {
if (isResolved) return;
isResolved = true;
this.manageResource(CONSTANTS.RESOURCE_KEYS.ACTIVE_FETCH, null);
if (observer) observer.disconnect();
if (timeoutId) clearTimeout(timeoutId);
stealthStyle.remove();
};
const closePanel = (panel) => {
// Strategy: Find the close button by its specific SVG path content
// This is robust against class name/jsname changes.
const closeIconPath = 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';
// Search for path elements within the panel
const paths = panel.querySelectorAll('svg path');
let closeBtn = null;
for (const path of paths) {
if (path.getAttribute('d') === closeIconPath) {
// Found the path, find the nearest button ancestor
closeBtn = path.closest('button');
break;
}
}
if (closeBtn) {
closeBtn.click();
} else {
// Fallback: Escape key
Logger.warn('CLOSE FALLBACK', '', 'Close button (SVG) not found. Sending Escape key.');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
};
// Setup Observer to watch for panel appearance and image src change
observer = new MutationObserver(() => {
// 1. Find the panel that matches the clicked docId
const panel = document.querySelector(`div[data-id="${docId}"]`);
if (panel) {
// Ensure the panel is inside an active container (aria-hidden="false")
const activeContainer = panel.closest('div[data-sci][aria-hidden="false"]');
if (!activeContainer) return;
// 2. Find the target image linked to the Landing Page (lpage)
let targetImg = null;
if (lpage) {
// Try exact match first, specifically targeting the main image link
targetImg = panel.querySelector(`a[href="${lpage}"][role="link"] img`);
}
// Fallback: Look for any link with role="link" which typically denotes the main image
if (!targetImg) {
targetImg = panel.querySelector('a[role="link"] > img');
}
if (targetImg && targetImg.src) {
lastKnownSrc = targetImg.src; // Update candidate
// 3. Check if src is a high-quality URL.
// We filter out known thumbnail/preview domains to wait for the original image.
const isLowRes = /encrypted-tbn|gstatic\.com|favicon/.test(targetImg.src);
if (targetImg.src.startsWith('http') && !isLowRes) {
finalize();
closePanel(panel);
resolve(targetImg.src);
}
}
}
});
// Start observing the body for subtree changes (panel insertion & attribute changes)
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'aria-hidden'],
});
// Manage active fetch instance via resource manager
this.manageResource(CONSTANTS.RESOURCE_KEYS.ACTIVE_FETCH, () => {
if (!isResolved) {
isResolved = true;
if (observer) observer.disconnect();
if (timeoutId) clearTimeout(timeoutId);
stealthStyle.remove();
resolve(null);
}
});
// Trigger Click with a slight delay to ensure observer is ready and browser has painted
requestAnimationFrame(() => {
trigger.click();
});
// Timeout Safety
// If the image remains a thumbnail (e.g. Instagram logic), we return the thumbnail as fallback.
timeoutId = setTimeout(() => {
// Try to find the panel to close it properly even on timeout
const panel = document.querySelector(`div[data-id="${docId}"]`);
finalize();
if (panel) {
closePanel(panel);
} else {
// Fallback if panel ref is lost or not found
Logger.warn('ASYNC_FAIL', '', 'Google: Panel not found or timed out.');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
resolve(lastKnownSrc);
}, CONSTANTS.TIMEOUTS.FETCH_ORIGINAL);
});
}
}
// =================================================================================
// SECTION: Sentinel (DOM Observer)
// =================================================================================
/**
* @class Sentinel
* @description Detects DOM node insertion using a shared, prefixed CSS animation trick.
* Designed as a persistent singleton per project prefix.
* This class does not support explicit lifecycle destruction (no destroy method), as instances are intended to live indefinitely to ensure continuous DOM monitoring across scripts.
* @property {Map<string, Set<(element: Element) => void>>} listeners
* @property {Set<string>} rules
* @property {HTMLElement | null} styleElement
* @property {CSSStyleSheet | null} sheet
* @property {WeakMap<CSSRule, string>} ruleSelectors
*/
class Sentinel {
static MAX_POLLS = 60;
static POLL_INTERVAL = 50;
/**
* @param {string} prefix - A unique identifier for this Sentinel instance to avoid CSS conflicts. Required.
*/
constructor(prefix) {
if (!prefix) {
throw new Error('[Sentinel] "prefix" argument is required to avoid CSS conflicts.');
}
// Validate prefix for CSS compatibility
// 1. Must contain only alphanumeric characters, hyphens, or underscores.
// 2. Cannot start with a digit.
// 3. Cannot start with a hyphen followed by a digit.
if (!/^[a-zA-Z0-9_-]+$/.test(prefix) || /^[0-9]|^-[0-9]/.test(prefix)) {
throw new Error(`[Sentinel] Prefix "${prefix}" is invalid. It must contain only alphanumeric characters, hyphens, or underscores, and cannot start with a digit or a hyphen followed by a digit.`);
}
/** @type {Window & { __global_sentinel_instances__?: Record<string, Sentinel> }} */
const globalScope = window;
globalScope.__global_sentinel_instances__ ??= {};
if (globalScope.__global_sentinel_instances__[prefix]) {
return globalScope.__global_sentinel_instances__[prefix];
}
this.prefix = prefix;
this.isSuspended = false;
// Use a unique, prefixed animation name shared by all scripts in a project.
this.animationName = `${prefix}-global-sentinel-animation`;
this.styleId = `${prefix}-sentinel-global-rules`; // A single, unified style element
this.listeners = new Map();
this.rules = new Set(); // Tracks all active selectors
this.styleElement = null; // Holds the reference to the single style element
this.sheet = null; // Cache the CSSStyleSheet reference
/** @type {WeakMap<CSSRule, string>} */
this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects
/** @type {Map<string, string>} */
this.normalizedSelectors = new Map(); // Maps original selectors to browser-normalized selectors
this._boundHandleAnimationStart = this._handleAnimationStart.bind(this);
this._injectStyleElement();
document.addEventListener('animationstart', this._boundHandleAnimationStart, true);
globalScope.__global_sentinel_instances__[prefix] = this;
}
_injectStyleElement() {
// Ensure the style element is injected only once per project prefix.
this.styleElement = document.getElementById(this.styleId);
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = this.isSuspended;
this._waitForStylesheet();
return;
}
// Create empty style element
this.styleElement = document.createElement('style');
this.styleElement.id = this.styleId;
// CSP Fix: Try to fetch a valid nonce from existing scripts/styles
// "nonce" property exists on HTMLScriptElement/HTMLStyleElement, not basic Element.
let nonce;
// 1. Try to get nonce from scripts collection
const scripts = document.scripts;
for (let i = 0; i < scripts.length; i++) {
if (scripts[i].nonce) {
nonce = scripts[i].nonce;
break;
}
}
// 2. Fallback: Using querySelector (content attribute)
if (!nonce) {
const style = document.querySelector('style[nonce]');
const script = document.querySelector('script[nonce]');
if (style instanceof HTMLStyleElement && style.nonce) {
nonce = style.nonce;
} else if (script instanceof HTMLScriptElement && script.nonce) {
nonce = script.nonce;
}
}
if (nonce) {
this.styleElement.nonce = nonce;
}
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = this.isSuspended;
}
// Try to inject immediately.
// If the document is not yet ready (e.g. extremely early document-start), wait for the root element.
const target = document.head || document.documentElement;
if (target) {
target.appendChild(this.styleElement);
this._waitForStylesheet();
} else {
const initObserver = new MutationObserver(() => {
const retryTarget = document.head || document.documentElement;
if (retryTarget) {
initObserver.disconnect();
retryTarget.appendChild(this.styleElement);
this._waitForStylesheet();
}
});
initObserver.observe(document, { childList: true });
}
}
/**
* Ensures the style element is connected to the DOM and restores rules if it was removed.
*/
_ensureStyleGuard() {
// Lazy Recovery: If the style element is connected but the stylesheet reference (this.sheet) was missed due to a timeout caused by a long task, recover it immediately here.
if (this.styleElement instanceof HTMLStyleElement && this.styleElement.isConnected && !this.sheet && this.styleElement.sheet) {
this._syncStylesheetRules();
}
if (this.styleElement && !this.styleElement.isConnected) {
const target = document.head || document.documentElement;
if (target) {
this.sheet = null; // Clear stale stylesheet reference before reconnecting
target.appendChild(this.styleElement);
this._waitForStylesheet();
}
}
}
/**
* Periodically checks for stylesheet availability and triggers full synchronization.
* @private
*/
_waitForStylesheet() {
if (!(this.styleElement instanceof HTMLStyleElement) || !this.styleElement.isConnected) return;
const styleNode = this.styleElement;
let pollCount = 0;
const poll = () => {
if (!styleNode.isConnected) return;
if (styleNode.sheet) {
this._syncStylesheetRules();
} else if (pollCount < Sentinel.MAX_POLLS) {
pollCount++;
console.debug(`[Sentinel] Polling sheet (Attempt ${pollCount}/${Sentinel.MAX_POLLS}). requestAnimationFrame check was insufficient.`);
setTimeout(poll, Sentinel.POLL_INTERVAL);
} else {
// Calculate timeout in seconds dynamically based on constants
const timeoutSeconds = (Sentinel.MAX_POLLS * Sentinel.POLL_INTERVAL) / 1000;
console.error(`[Sentinel] Polling sheet timed out after ${timeoutSeconds} seconds.`);
}
};
if (styleNode.sheet) {
this._syncStylesheetRules();
} else {
requestAnimationFrame(() => {
if (!styleNode.isConnected) return;
if (styleNode.sheet) {
this._syncStylesheetRules();
} else {
setTimeout(poll, Sentinel.POLL_INTERVAL);
}
});
}
}
/**
* Synchronizes all active rules directly onto the connected stylesheet.
* @private
*/
_syncStylesheetRules() {
if (!(this.styleElement instanceof HTMLStyleElement) || !this.styleElement.isConnected || !this.styleElement.sheet) return;
this.styleElement.disabled = this.isSuspended;
this.sheet = this.styleElement.sheet;
try {
// Non-destructive cleanup: scan and remove only rules belonging to this instance's active selectors
for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
const rule = this.sheet.cssRules[i];
const recordedSelector = this.ruleSelectors.get(rule);
if (this.rules.has(recordedSelector) || (rule instanceof CSSStyleRule && (this.rules.has(rule.selectorText) || [...this.rules].some((sel) => rule.selectorText === this.normalizedSelectors.get(sel))))) {
this.sheet.deleteRule(i);
}
}
// Non-destructive keyframes validation
this._ensureKeyframesRule();
} catch (e) {
console.error('[Sentinel] Failed to clear or restore base rules:', e);
}
this.rules.forEach((selector) => {
const success = this._insertRule(selector);
if (!success) {
// Rollback invalid selector to prevent infinite error loops on subsequent syncs
this.rules.delete(selector);
this.listeners.delete(selector);
}
});
}
/**
* Ensures the shared keyframes rule exists in the stylesheet.
*/
_ensureKeyframesRule() {
let hasKeyframes = false;
for (let i = 0; i < this.sheet.cssRules.length; i++) {
const rule = this.sheet.cssRules[i];
if (rule instanceof CSSKeyframesRule && rule.name === this.animationName) {
hasKeyframes = true;
break;
}
}
if (!hasKeyframes) {
const keyframes = `@keyframes ${this.animationName} { from { outline: 1px solid transparent; } to { outline: 0px solid transparent; } }`;
this.sheet.insertRule(keyframes, 0);
}
}
/**
* Helper to insert a single rule into the stylesheet
* @param {string} selector
* @returns {boolean} True if insertion was successful, false otherwise
*/
_insertRule(selector) {
try {
const index = this.sheet.cssRules.length;
const ruleText = `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`;
this.sheet.insertRule(ruleText, index);
// Associate the inserted rule with the selector via WeakMap for safer removal later.
// This mimics sentinel.js behavior to handle index shifts and selector normalization.
const insertedRule = this.sheet.cssRules[index];
if (insertedRule) {
this.ruleSelectors.set(insertedRule, selector);
if (insertedRule instanceof CSSStyleRule) {
this.normalizedSelectors.set(selector, insertedRule.selectorText);
}
}
return true;
} catch (e) {
console.error(`[Sentinel] Rule insertion failed for selector "${selector}". The listener has been rejected and removed:`, e);
return false;
}
}
_handleAnimationStart(event) {
if (this.isSuspended) return;
// Check if the animation is the one we're listening for.
if (event.animationName !== this.animationName) return;
const target = event.target;
if (!(target instanceof Element)) {
return;
}
// Check if the target element matches any of this instance's selectors.
for (const [selector, callbacks] of this.listeners.entries()) {
if (target.matches(selector)) {
// Use a copy of the callbacks Set in case a callback removes itself.
[...callbacks].forEach((cb) => {
try {
cb(target);
} catch (e) {
console.error(`[Sentinel] Listener error for selector "${selector}":`, e);
}
});
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
on(selector, callback) {
this._ensureStyleGuard();
// Add callback to listeners
if (!this.listeners.has(selector)) {
this.listeners.set(selector, new Set());
}
this.listeners.get(selector).add(callback);
// If selector is already registered in rules, do nothing
if (this.rules.has(selector)) return;
this.rules.add(selector);
// Apply rule
if (this.sheet) {
const success = this._insertRule(selector);
if (!success) {
// Rollback on immediate insertion failure
this.listeners.delete(selector);
this.rules.delete(selector);
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
off(selector, callback) {
const callbacks = this.listeners.get(selector);
if (!callbacks) return;
const wasDeleted = callbacks.delete(callback);
if (!wasDeleted) {
return;
// Callback not found, do nothing.
}
if (callbacks.size === 0) {
// Remove listener and rule
this.listeners.delete(selector);
this.rules.delete(selector);
this.normalizedSelectors.delete(selector);
if (this.sheet) {
// Iterate backwards to avoid index shifting issues during deletion
for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
const rule = this.sheet.cssRules[i];
// Check for recorded selector via WeakMap or fallback to selectorText match
const recordedSelector = this.ruleSelectors.get(rule);
if (recordedSelector === selector || (rule instanceof CSSStyleRule && (rule.selectorText === selector || rule.selectorText === this.normalizedSelectors.get(selector)))) {
try {
this.sheet.deleteRule(i);
} catch (e) {
console.error(`[Sentinel] Failed to delete rule for selector "${selector}":`, e);
}
// We assume one rule per selector, so we can break after deletion
break;
}
}
}
}
}
suspend() {
if (this.isSuspended) return;
this.isSuspended = true;
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = true;
}
console.debug('[Sentinel] Suspended.');
}
resume() {
if (!this.isSuspended) return;
this.isSuspended = false;
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = false;
}
console.debug('[Sentinel] Resumed.');
}
}
// =================================================================================
// SECTION: Navigation Monitor
// Description: Centralizes URL change detection via history API hooks and popstate events.
// =================================================================================
/**
* @class NavigationMonitor
* @description A shared, safe History API wrapper to detect SPA navigations. Prevents infinite nesting and conflicts across multiple userscripts.
* * [USAGE NOTES]
* - **Singleton Coordination**: This class acts as a centralized Singleton coordinator per `ownerId` to multiplex navigation events across multiple script instances seamlessly.
* - **Subscriber-Based Idempotency**: Subscriptions are uniquely identified via a `subscriberId`. Registering a subscriber with an existing ID will safely cancel its pending debounced execution and overwrite it with the new configuration.
* - **Persistent Hooking**: For cross-script stability, this coordinator does not implement a `destroy` or history restoration mechanism. The History API hooks remain active permanently.
* - **Listener Lifecycle**: Invoke the unsubscription token function returned by the `on()` method to safely remove the script's listeners from the shared coordinator.
*/
class NavigationMonitor {
static POST_NAVIGATION_DOM_SETTLE = 200;
/**
* @param {string} ownerId - Unique identifier to share the hook ecosystem.
*/
constructor(ownerId) {
this.ownerId = ownerId;
/** @type {Window & { __global_nav_coordinators__?: Record<string, any> }} */
const globalScope = window;
globalScope.__global_nav_coordinators__ ??= {};
if (globalScope.__global_nav_coordinators__[ownerId]) {
return globalScope.__global_nav_coordinators__[ownerId];
}
this.listeners = new Map();
this.originalHistoryMethods = { pushState: null, replaceState: null };
this.isHooked = false;
this._boundHandlePopState = null;
this._boundHandleCustomNavEvent = null;
this.lastPath = null;
globalScope.__global_nav_coordinators__[ownerId] = this;
}
/**
* @param {string} subscriberId - Unique key to identify the subscriber and prevent duplicates.
* @param {Function} onNavStart
* @param {Function} onNavSettled
* @param {Object} options - Configuration parameters for the navigation subscription.
* @param {boolean} options.trackHash - Specifies whether the subscriber triggers on location.hash modifications.
* @returns {() => void} A function to unsubscribe this listener pair.
*/
on(subscriberId, onNavStart, onNavSettled, options) {
/** @type {Window & { __global_nav_coordinators__?: Record<string, any> }} */
const globalScope = window;
const coordinator = globalScope.__global_nav_coordinators__[this.ownerId];
if (!coordinator) return () => {};
// If a subscriber with the same subscriberId already exists, cancel its debounce and overwrite it safely.
if (coordinator.listeners.has(subscriberId)) {
const existingPair = coordinator.listeners.get(subscriberId);
existingPair.debouncedNavigation.cancel();
coordinator.listeners.delete(subscriberId);
}
// Configure hash-tracking behavior based on the explicit subscription option.
const trackHash = options.trackHash;
const initialPath = trackHash ? location.pathname + location.search + location.hash : location.pathname + location.search;
// Bundle the start callback and its corresponding debounced settled callback.
const listenerPair = {
onNavStart,
debouncedNavigation: debounce(onNavSettled, NavigationMonitor.POST_NAVIGATION_DOM_SETTLE, true),
trackHash,
lastPath: initialPath,
};
coordinator.listeners.set(subscriberId, listenerPair);
if (!coordinator.isHooked) {
coordinator.lastPath = location.pathname + location.search + location.hash;
coordinator._boundHandleCustomNavEvent = () => {
const fullPath = location.pathname + location.search + location.hash;
if (fullPath === coordinator.lastPath) return;
coordinator.lastPath = fullPath;
for (const pair of coordinator.listeners.values()) {
const currentPath = pair.trackHash ? fullPath : location.pathname + location.search;
if (currentPath !== pair.lastPath) {
pair.lastPath = currentPath;
pair.onNavStart();
pair.debouncedNavigation();
}
}
};
coordinator._boundHandlePopState = () => {
globalScope.dispatchEvent(new CustomEvent(`${this.ownerId}:locationchange`));
};
this._hookHistory(coordinator);
globalScope.addEventListener(`${this.ownerId}:locationchange`, coordinator._boundHandleCustomNavEvent);
globalScope.addEventListener('popstate', coordinator._boundHandlePopState);
globalScope.addEventListener('hashchange', coordinator._boundHandlePopState);
}
// Return the unsubscription token closure directly.
return () => {
listenerPair.debouncedNavigation.cancel();
// Only remove from coordinator if this specific listener instance is still active
if (coordinator.listeners.get(subscriberId) === listenerPair) {
coordinator.listeners.delete(subscriberId);
}
};
}
/**
* @private
* @param {Object} coordinator
*/
_hookHistory(coordinator) {
const hookFlag = `__${this.ownerId}_HISTORY_HOOKED__`;
/** @type {Window & { __global_nav_coordinators__?: Record<string, any> }} */
const globalScope = window;
if (!coordinator.isHooked && !globalScope[hookFlag]) {
globalScope[hookFlag] = true;
coordinator.isHooked = true;
const ownerId = this.ownerId;
for (const m of ['pushState', 'replaceState']) {
const orig = history[m];
coordinator.originalHistoryMethods[m] = orig;
// [DO NOT REFACTOR] `wrapper` must remain a standard function to safely capture the execution-time `this`.
/** @this {History} */
const wrapper = function (...args) {
try {
return orig.apply(this, args);
} finally {
// Always dispatch the event via 'finally' to ensure navigation state changes
// are broadcasted, even if the native history method or another hooked wrapper throws an error.
globalScope.dispatchEvent(new CustomEvent(`${ownerId}:locationchange`));
}
};
history[m] = wrapper;
}
}
}
}
// =================================================================================
// SECTION: Main Application Controller
// =================================================================================
class AppController extends BaseManager {
constructor() {
super();
/** @type {ConfigManager} */
this.configManager = new ConfigManager();
/** @type {UIManager} */
this.uiManager = new UIManager(this.configManager);
/** @type {Sentinel} */
this.sentinel = new Sentinel(OWNERID);
/** @type {NavigationMonitor} */
this.navMonitor = new NavigationMonitor(OWNERID);
// State
/** @type {BaseAdapter} */
this.adapter = null;
/** @type {string|null} */
this.activeSentinelSelector = null;
/** @type {Function|null} */
this.boundResultHandler = null;
}
/**
* Initializes the script.
*/
async init() {
// 1. Load configuration asynchronously
await this.configManager.load();
// 2. Initialize Navigation Monitor (SPA Support)
const onNavStart = () => {};
const onNavSettled = () => {
EventBus.publish(EVENTS.NAVIGATION);
};
const subscriberKey = createSubscriberKey(CONSTANTS.NAV_PURPOSE.MAIN);
const unsubscribe = this.navMonitor.on(subscriberKey, onNavStart, onNavSettled, { trackHash: false });
this.addDisposable(unsubscribe);
this.registerPlatformListener(EVENTS.NAVIGATION, this._reconcileAdapter.bind(this));
// 3. Initial Adapter Reconciliation
await this._reconcileAdapter();
// 4. Register Menu Command (Global)
// Note: Settings modal is lazy-loaded with platform styles when needed, or default styles if no adapter is active.
GM.registerMenuCommand('Open Settings', () => {
this._openSettings();
});
}
/**
* Checks the current URL and mounts/unmounts the appropriate adapter.
*/
async _reconcileAdapter() {
const adapters = [GoogleAdapter, BingAdapter, DuckDuckGoAdapter];
const currentUrl = window.location.href;
// Check if current adapter is still applicable
if (this.adapter) {
if (this.adapter.constructor.isApplicable()) {
Logger.debug('DEBUG', '', 'Current adapter is still applicable. Forcing rescan for content updates.');
this._forceRescan();
return;
}
// Current adapter is no longer valid
Logger.info('NAV', '', 'Current adapter no longer applicable. Unmounting...');
this._unmountAdapter();
}
// Find new applicable adapter
const AdapterClass = adapters.find((a) => a.isApplicable());
if (AdapterClass) {
Logger.info('NAV', LOG_STYLES.GREEN, `Switching to adapter: ${AdapterClass.id.toUpperCase()}`);
await this._mountAdapter(AdapterClass);
} else {
Logger.info('NAV', LOG_STYLES.GRAY, `No applicable adapter found for: ${currentUrl}`);
}
}
/**
* Forces a rescan of the DOM to handle dynamic content updates (SPA navigation).
* Removes processed flags and manually triggers handlers for existing elements.
*/
_forceRescan() {
if (!this.adapter || !this.activeSentinelSelector) return;
Logger.info('RESCAN', LOG_STYLES.BLUE, 'Rescanning DOM for new content...');
// 1. Reset processed flags to allow re-detection
const processedElements = document.querySelectorAll(`.${CONSTANTS.PROCESSED_CLASS}`);
processedElements.forEach((el) => el.classList.remove(CONSTANTS.PROCESSED_CLASS));
// 2. Manually query matches using the sentinel selector
// Since we removed the exclusion class, the selector will match all valid items again.
const targets = document.querySelectorAll(this.activeSentinelSelector);
Logger.debug('DEBUG', '', `Manual rescan found ${targets.length} candidates.`);
targets.forEach((el) => {
this.adapter.onResultFound(el);
});
}
/**
* Mounts the specified adapter and starts observation.
* @param {typeof BaseAdapter} AdapterClass
*/
async _mountAdapter(AdapterClass) {
// Get Platform Styles
const platformStyles = SITE_STYLES[AdapterClass.id];
// Inject Platform-Specific Overrides (Layout)
if (platformStyles.overrides) {
const style = document.createElement('style');
style.id = `${APPID}-platform-overrides`;
style.textContent = platformStyles.overrides;
const nonce = document.querySelector('script[nonce]')?.nonce;
if (nonce) style.setAttribute('nonce', nonce);
document.head.appendChild(style);
}
// Initialize UI Manager with platform styles
this.uiManager.init(platformStyles);
// Instantiate Adapter using factory manager
this.adapter = this.manageFactory(CONSTANTS.RESOURCE_KEYS.ADAPTER, () => new AdapterClass(this.uiManager));
// Register async fetcher if supported
if (typeof this.adapter.fetchOriginalImageUrl === 'function' && this.adapter.fetchOriginalImageUrl !== BaseAdapter.prototype.fetchOriginalImageUrl) {
this.uiManager.setOriginalImageFetcher(this.adapter.fetchOriginalImageUrl.bind(this.adapter));
} else {
// Reset fetcher if previous adapter had one but this one doesn't
this.uiManager.setOriginalImageFetcher(null);
}
Logger.log('INIT', LOG_STYLES.GREEN, `Mounted ${AdapterClass.id.toUpperCase()}`);
// Start Sentinel Observation
const selector = this.adapter.getSentinelSelector();
if (selector) {
this.activeSentinelSelector = selector;
// Bind handler to maintain "this" context and allow removal
this.boundResultHandler = (el) => {
if (this.adapter) {
this.adapter.onResultFound(el);
// Smoke Test: Validate extraction on the first result found.
// Triggers validation logic inside extractUrls.
if (!this.adapter.hasSmokeTested) {
this.adapter.hasSmokeTested = true;
this.adapter.extractUrls(el);
}
}
};
Logger.info('SENTINEL', LOG_STYLES.BLUE, `Observing: "${selector}"`);
this.sentinel.on(selector, this.boundResultHandler);
this.adapter.addDisposable(() => this.sentinel.off(selector, this.boundResultHandler));
} else {
Logger.warn('INIT WARN', '', 'No sentinel selector defined for this adapter.');
}
}
/**
* Unmounts the current adapter and stops observation.
*/
_unmountAdapter() {
if (!this.adapter) return;
// Unmount via BaseManager resource management
this.manageResource(CONSTANTS.RESOURCE_KEYS.ADAPTER, null);
if (this.activeSentinelSelector && this.boundResultHandler) {
this.activeSentinelSelector = null;
this.boundResultHandler = null;
}
// Remove Platform Overrides
const style = document.getElementById(`${APPID}-platform-overrides`);
if (style) style.remove();
// Clear reference
this.adapter = null;
Logger.info('TERM', LOG_STYLES.GRAY, 'Adapter unmounted.');
}
/**
* Opens settings modal with appropriate styles.
*/
_openSettings() {
// Determine styles: use active adapter's styles or fallback to Google's (as generic dark theme)
let styles = SITE_STYLES.google;
if (this.adapter) {
styles = SITE_STYLES[this.adapter.constructor.id];
}
// Manage settings modal instance via factory manager
const modal = this.manageFactory(CONSTANTS.RESOURCE_KEYS.SETTINGS_MODAL, () => new SettingsModal(this.configManager, styles));
if (modal) {
modal.open();
}
}
}
// =================================================================================
// SECTION: Entry Point
// =================================================================================
if (ExecutionGuard.hasExecuted()) return;
ExecutionGuard.setExecuted();
// 1. Instantiate controller immediately.
const app = new AppController();
// 2. Initialize the app immediately.
// The init method checks isApplicable() internally.
app.init().catch((e) => {
Logger.error('INIT ERROR', '', 'Failed to initialize app:', e);
});
})();