Popup under cursor (if fits), hide on click any button, preload enabled icons on page load, 5-per-row, /favicon.ico with fallback; add/remove sites in Settings. Now with "reuse existing tab", "toggle copy button", "auto-copy on select" and import/export features!
// ==UserScript==
// @name Stylish Search Popup + Sites Manager
// @namespace http://tampermonkey.net/
// @version 2025.11.02
// @author TesterTV
// @homepageURL https://github.com/testertv/StylishSearchPopup
// @license GPL v.3 or any later version.
// @namespace https://greasyfork.org/ru/scripts/554584-stylish-search-popup-sites-manager
// @description Popup under cursor (if fits), hide on click any button, preload enabled icons on page load, 5-per-row, /favicon.ico with fallback; add/remove sites in Settings. Now with "reuse existing tab", "toggle copy button", "auto-copy on select" and import/export features!
// @match *://*/*
// @grant GM_addStyle
// @grant GM_openInTab
// @grant GM_registerMenuCommand
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_setClipboard
// @grant GM.addValueChangeListener
// ==/UserScript==
(async function () {
'use strict';
const POPUP_ID = 'stylish-search-popup-container';
const SETTINGS_OVERLAY_ID = 'ssp-settings-overlay';
const SETTINGS_MODAL_ID = 'ssp-settings-modal';
const STORAGE_KEYS = {
enabledEngineKeys: 'ssp_enabledEngineKeys',
engines: 'ssp_engines',
reuseTab: 'ssp_reuseTab',
showCopyButton: 'ssp_showCopyButton',
autoCopyOnSelect: 'ssp_autoCopyOnSelect', // New storage key
searchRequest: 'ssp_searchRequest',
};
const DEFAULT_ENGINES = [
{ key: 'google', name: 'Google', iconHost: 'google.com', url: 'https://www.google.com/search?q={query}' },
{ key: 'duckduckgo', name: 'DuckDuckGo', iconHost: 'duckduckgo.com', url: 'https://duckduckgo.com/?q={query}' },
{ key: 'startpage', name: 'Startpage', iconHost: 'startpage.com', url: 'https://www.startpage.com/search?q={query}' },
{ key: 'bing', name: 'Bing', iconHost: 'bing.com', url: 'https://www.bing.com/search?q={query}' },
{ key: 'yahoo', name: 'Yahoo', iconHost: 'yahoo.com', url: 'https://search.yahoo.com/search?p={query}' },
{ key: 'brave', name: 'Brave', iconHost: 'brave.com', url: 'https://search.brave.com/search?q={query}&source=web' },
{ key: 'yandex', name: 'Yandex', iconHost: 'yandex.com', url: 'https://yandex.com/search/?text={query}' },
{ key: 'youtube', name: 'YouTube', iconHost: 'youtube.com', url: 'https://www.youtube.com/results?search_query={query}' },
{ key: 'tiktok', name: 'TikTok', iconHost: 'tiktok.com', url: 'https://www.tiktok.com/search?q={query}' },
{ key: 'twitch', name: 'Twitch', iconHost: 'twitch.tv', url: 'https://www.twitch.tv/search?term={query}' },
{ key: 'deepl', name: 'Deepl (De->Ru)', iconHost: 'deepl.com', url: 'https://www.deepl.com/en/translator#de/ru/{query}' },
{ key: 'gtranslate', name: 'Google Translate (Auto->Ru)', iconHost: 'translate.google.com', url: 'https://translate.google.com/?sl=auto&tl=ru&text={query}&op=translate' },
{ key: 'facebook', name: 'Facebook', iconHost: 'facebook.com', url: 'https://www.facebook.com/search/top/?q={query}' },
{ key: 'instagram', name: 'Instagram', iconHost: 'instagram.com', url: 'https://www.instagram.com/explore/search/keyword/?q={query}' },
{ key: 'x', name: 'X', iconHost: 'x.com', url: 'https://x.com/search?q={query}' },
{ key: 'wikipedia', name: 'Wikipedia', iconHost: 'wikipedia.org', url: 'https://www.wikipedia.org/search-redirect.php?search={query}' },
{ key: 'reddit', name: 'Reddit', iconHost: 'reddit.com', url: 'https://www.reddit.com/search/?q={query}' },
{ key: 'amazon', name: 'Amazon', iconHost: 'amazon.com', url: 'https://www.amazon.com/s?k={query}' },
{ key: 'temu', name: 'Temu', iconHost: 'temu.com', url: 'https://www.temu.com/search_result.html?search_key={query}' },
{ key: 'ebay', name: 'eBay', iconHost: 'ebay.com', url: 'https://www.ebay.com/sch/i.html?_nkw={query}' },
{ key: 'walmart', name: 'Walmart', iconHost: 'walmart.com', url: 'https://www.walmart.com/search/?query={query}' },
{ key: 'aliexpress', name: 'AliExpress', iconHost: 'aliexpress.com', url: 'https://www.aliexpress.com/wholesale?SearchText={query}' },
{ key: 'netflix', name: 'Netflix', iconHost: 'netflix.com', url: 'https://www.netflix.com/search?q={query}' },
{ key: 'imdb', name: 'IMDb', iconHost: 'imdb.com', url: 'https://www.imdb.com/find/?q={query}' },
{ key: 'kinopoisk', name: 'Кинопоиск', iconHost: 'kinopoisk.ru', url: 'https://www.kinopoisk.ru/index.php?kp_query={query}' },
{ key: 'fandom', name: 'Fandom', iconHost: 'fandom.com', url: 'https://www.fandom.com/?s={query}' },
{ key: 'pinterest', name: 'Pinterest', iconHost: 'pinterest.com', url: 'https://www.pinterest.com/search/pins/?q={query}' },
{ key: 'github', name: 'GitHub', iconHost: 'github.com', url: 'https://github.com/search?q={query}' },
{ key: 'stackoverflow', name: 'Stack Overflow', iconHost: 'stackoverflow.com', url: 'https://stackoverflow.com/search?q={query}' },
{ key: 'steam', name: 'Steam', iconHost: 'store.steampowered.com/', url: 'https://store.steampowered.com/search/?term={query}' },
{ key: 'linkedin', name: 'LinkedIn', iconHost: 'linkedin.com', url: 'https://www.linkedin.com/search/results/all/?keywords={query}' },
{ key: 'webarchives', name: 'Web Archives', iconHost: 'archive.org', url: 'https://web.archive.org/web/*/{query}' },
{ key: 'rutracker', name: 'RuTracker', iconHost: 'rutracker.org', url: 'https://rutracker.org/forum/tracker.php?nm={query}' },
{ key: 'kinozal', name: 'Кинозал.ТВ', iconHost: 'kinozal.tv', url: 'https://kinozal.tv/browse.php?s={query}' },
{ key: 'rutor', name: 'rutor', iconHost: 'rutor.info', url: 'https://rutor.info/search/{query}' },
];
// Helpers for icons
const buildIconUrl = (host) => `https://${host}/favicon.ico`;
const buildFallbackIconUrl = (host) => {
const clean = host.replace(/^www\./, '');
return `https://icons.duckduckgo.com/ip3/${clean}.ico`;
};
// Load engines + enabled keys
function validateEngines(list) {
const seen = new Set();
const valid = [];
for (const e of Array.isArray(list) ? list : []) {
if (!e || !e.key || !e.name || !e.iconHost || !e.url) continue;
if (seen.has(e.key)) continue;
seen.add(e.key);
valid.push({ key: String(e.key), name: String(e.name), iconHost: String(e.iconHost), url: String(e.url) });
}
return valid.length ? valid : DEFAULT_ENGINES.slice();
}
let allEngines = validateEngines(await GM.getValue(STORAGE_KEYS.engines, DEFAULT_ENGINES));
let enabledEngineKeys = await GM.getValue(
STORAGE_KEYS.enabledEngineKeys,
allEngines.map(e => e.key)
);
enabledEngineKeys = enabledEngineKeys.filter(k => allEngines.some(e => e.key === k));
let reuseTabEnabled = await GM.getValue(STORAGE_KEYS.reuseTab, false);
let showCopyButtonEnabled = await GM.getValue(STORAGE_KEYS.showCopyButton, true);
let autoCopyOnSelectEnabled = await GM.getValue(STORAGE_KEYS.autoCopyOnSelect, false); // New setting variable
const getEnabledEngines = () =>
allEngines.filter(e => enabledEngineKeys.includes(e.key));
async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text);
return true;
}
} catch {}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {}
return false;
}
// Icon preload
let iconSrcMap = new Map();
function waitForImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.referrerPolicy = 'no-referrer';
img.onload = () => resolve(url);
img.onerror = () => reject(url);
img.src = url;
});
}
async function preloadIconForHost(host) {
const primary = buildIconUrl(host);
try {
return await waitForImage(primary);
} catch {
const fallback = buildFallbackIconUrl(host);
try {
return await waitForImage(fallback);
} catch {
return fallback;
}
}
}
async function preloadIcons(enginesList) {
await Promise.all(
enginesList.map(async (engine) => {
const src = await preloadIconForHost(engine.iconHost);
iconSrcMap.set(engine.key, src);
})
);
}
preloadIcons(getEnabledEngines()).catch(() => {});
// UI helpers
function createIconImgForEngine(engine) {
const img = document.createElement('img');
img.alt = engine.name;
img.referrerPolicy = 'no-referrer';
img.decoding = 'async';
const preloaded = iconSrcMap.get(engine.key);
img.src = preloaded || buildIconUrl(engine.iconHost);
img.addEventListener('error', () => {
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = buildFallbackIconUrl(engine.iconHost);
});
return img;
}
function slugify(str) {
return String(str || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'site';
}
function generateUniqueKey(base, existingKeys) {
let key = slugify(base);
if (!key) key = 'site';
let out = key;
let i = 2;
const set = new Set(existingKeys);
while (set.has(out)) {
out = `${key}-${i++}`;
}
return out;
}
function normalizeHost(input) {
const s = String(input || '').trim();
if (!s) return '';
try {
const u = /^https?:\/\//i.test(s) ? new URL(s) : new URL(`https://${s}`);
return u.hostname;
} catch {
return s.replace(/^https?:\/\//i, '').split('/')[0];
}
}
async function saveAll(engines, enabledKeys, reuseTab, showCopy, autoCopy) {
allEngines = engines.slice();
enabledEngineKeys = enabledKeys.filter(k => allEngines.some(e => e.key === k));
reuseTabEnabled = !!reuseTab;
showCopyButtonEnabled = !!showCopy;
autoCopyOnSelectEnabled = !!autoCopy; // Save new setting state
await GM.setValue(STORAGE_KEYS.engines, allEngines);
await GM.setValue(STORAGE_KEYS.enabledEngineKeys, enabledEngineKeys);
await GM.setValue(STORAGE_KEYS.reuseTab, reuseTabEnabled);
await GM.setValue(STORAGE_KEYS.showCopyButton, showCopyButtonEnabled);
await GM.setValue(STORAGE_KEYS.autoCopyOnSelect, autoCopyOnSelectEnabled); // Persist new setting
iconSrcMap.clear();
await preloadIcons(allEngines.filter(e => enabledEngineKeys.includes(e.key)));
}
GM_addStyle(`
#${POPUP_ID} {
--tile: 34px;
--gap: 6px;
--maxCols: 5;
position: absolute;
z-index: 999999999;
background-color: rgba(40, 42, 54, 0.95);
border-radius: 8px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 6px;
gap: var(--gap);
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
width: max-content;
max-width: calc(var(--tile) * var(--maxCols) + var(--gap) * (var(--maxCols) - 1));
}
#${POPUP_ID}.visible { opacity: 1; transform: translateY(0); }
#${POPUP_ID} a {
display: flex;
align-items: center;
justify-content: center;
width: var(--tile);
height: var(--tile);
background-color: rgba(255, 255, 255, 0.1);
border-radius: 6px;
transition: all 0.2s ease;
cursor: pointer;
user-select: none;
flex: 0 0 var(--tile);
}
#${POPUP_ID} a:hover {
background-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
#${POPUP_ID} img {
width: 18px;
height: 18px;
object-fit: contain;
border-radius: 2px;
}
#${POPUP_ID} .ssp-settings-btn,
#${POPUP_ID} .ssp-copy-btn {
font-size: 18px;
line-height: 1;
color: #fff;
font-family: system-ui, sans-serif;
}
/* Settings modal */
#${SETTINGS_OVERLAY_ID} {
position: fixed;
inset: 0;
z-index: 2147483647;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
}
#${SETTINGS_MODAL_ID} {
width: min(780px, 95vw);
max-height: 85vh;
overflow: auto;
background: #1e1f25;
color: #fff;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
padding: 16px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
#${SETTINGS_MODAL_ID} h2 { margin: 0 0 10px; font-size: 18px; }
#${SETTINGS_MODAL_ID} .ssp-top {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
#${SETTINGS_MODAL_ID} .ssp-muted { opacity: 0.75; }
#${SETTINGS_MODAL_ID} .ssp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px; margin: 12px 0;
}
#${SETTINGS_MODAL_ID} .ssp-item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 8px; background: rgba(255,255,255,0.07); border-radius: 8px;
}
#${SETTINGS_MODAL_ID} .ssp-item label {
display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%;
flex: 1;
}
#${SETTINGS_MODAL_ID} .ssp-item img { width: 18px; height: 18px; border-radius: 3px; }
#${SETTINGS_MODAL_ID} .ssp-row-actions { display: flex; gap: 6px; }
#${SETTINGS_MODAL_ID} .ssp-icon-btn {
border: 0; padding: 6px; border-radius: 6px; font-weight: 600; cursor: pointer;
background: #2e303b; color: #fff; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
}
#${SETTINGS_MODAL_ID} .ssp-icon-btn:hover { background: #3a3d4a; }
#${SETTINGS_MODAL_ID} .ssp-actions { display: flex; gap: 8px; justify-content: space-between; margin-top: 12px; align-items: flex-end; }
#${SETTINGS_MODAL_ID} .ssp-actions-right { display: flex; gap: 8px; }
#${SETTINGS_MODAL_ID} button { border: 0; padding: 8px 12px; border-radius: 8px; font-weight: 600; cursor: pointer; }
#${SETTINGS_MODAL_ID} .ssp-btn { background: #2e303b; color: #fff; }
#${SETTINGS_MODAL_ID} .ssp-btn:hover { background: #3a3d4a; }
#${SETTINGS_MODAL_ID} .ssp-primary { background: #4c7cf3; color: #fff; }
#${SETTINGS_MODAL_ID} .ssp-primary:hover { background: #3f6be0; }
#${SETTINGS_MODAL_ID} .ssp-options-label {
display: flex; align-items: center; gap: 8px; cursor: pointer;
}
#${SETTINGS_MODAL_ID} .ssp-btn-group { display: flex; gap: 8px; }
#${SETTINGS_MODAL_ID} .ssp-actions-left { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
/* Editor overlay inside settings */
#${SETTINGS_MODAL_ID} .ssp-editor {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: none; align-items: center; justify-content: center;
}
#${SETTINGS_MODAL_ID} .ssp-editor.visible { display: flex; }
#${SETTINGS_MODAL_ID} .ssp-editor-card {
background: #1f212a; color: #fff; border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px; padding: 14px; width: min(520px, 95vw);
}
#${SETTINGS_MODAL_ID} .ssp-form { display: flex; flex-direction: column; gap: 10px; }
#${SETTINGS_MODAL_ID} .ssp-form label span { display: block; margin-bottom: 4px; opacity: 0.8; font-size: 12px; }
#${SETTINGS_MODAL_ID} .ssp-form input {
width: 100%; padding: 8px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15); background: #2b2d38; color: #fff;
}
#${SETTINGS_MODAL_ID} .ssp-form .hint { font-size: 12px; opacity: 0.7; }
#${SETTINGS_MODAL_ID} .ssp-form .editor-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 6px; }
`);
let popup = null;
let searchRequestPending = null;
function positionPopupAtCursor(popupEl, pageX, pageY) {
const margin = 10;
const offsetBelow = 12;
const offsetAbove = 8;
const popupW = popupEl.offsetWidth;
const popupH = popupEl.offsetHeight;
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const scrollX = window.pageXOffset;
const scrollY = window.pageYOffset;
const clientX = pageX - scrollX;
const clientY = pageY - scrollY;
// Horizontal: center, keep inside viewport
let left = pageX - popupW / 2;
left = Math.max(scrollX + margin, Math.min(left, scrollX + viewportW - popupW - margin));
// Vertical: prefer below, else above, else clamp
const belowTop = pageY + offsetBelow;
const belowBottomClient = clientY + offsetBelow + popupH;
let top;
if (belowBottomClient <= viewportH - margin) {
top = belowTop;
} else {
const aboveTop = pageY - popupH - offsetAbove;
const aboveTopClient = clientY - popupH - offsetAbove;
if (aboveTopClient >= margin) {
top = aboveTop;
} else {
top = Math.max(scrollY + margin, Math.min(pageY - popupH / 2, scrollY + viewportH - popupH - margin));
}
}
popupEl.style.left = `${left}px`;
popupEl.style.top = `${top}px`;
}
function createPopup(x, y, selectedText) {
hidePopup();
popup = document.createElement('div');
popup.id = POPUP_ID;
// Copy
if (showCopyButtonEnabled) {
const copyBtn = document.createElement('a');
copyBtn.className = 'ssp-copy-btn';
copyBtn.title = 'Copy to clipboard';
copyBtn.textContent = '📋';
copyBtn.addEventListener('mousedown', async (e) => {
e.preventDefault();
e.stopPropagation();
await copyToClipboard(selectedText);
hidePopup();
});
popup.appendChild(copyBtn);
}
// Engines
const enginesToShow = getEnabledEngines();
enginesToShow.forEach(engine => {
const button = document.createElement('a');
button.title = `Open in ${engine.name}`;
const img = createIconImgForEngine(engine);
button.appendChild(img);
button.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const query = encodeURIComponent(selectedText);
const searchUrl = engine.url.replace('{query}', query);
if (reuseTabEnabled) {
const requestId = Date.now();
searchRequestPending = requestId;
GM.setValue(STORAGE_KEYS.searchRequest, {
url: searchUrl,
host: engine.iconHost,
id: requestId,
});
setTimeout(() => {
if (searchRequestPending === requestId) {
GM_openInTab(searchUrl, { active: true });
searchRequestPending = null;
}
}, 300);
} else {
GM_openInTab(searchUrl, { active: true });
}
hidePopup();
});
popup.appendChild(button);
});
// Settings
const settingsBtn = document.createElement('a');
settingsBtn.className = 'ssp-settings-btn';
settingsBtn.title = 'Settings';
settingsBtn.textContent = '⚙️';
settingsBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
hidePopup();
openSettingsModal();
});
popup.appendChild(settingsBtn);
document.body.appendChild(popup);
positionPopupAtCursor(popup, x, y);
setTimeout(() => popup.classList.add('visible'), 10);
}
function hidePopup() {
const existingPopup = document.getElementById(POPUP_ID);
if (existingPopup) {
existingPopup.classList.remove('visible');
setTimeout(() => existingPopup?.remove(), 200);
}
popup = null;
}
function openSettingsModal() {
closeSettingsModal();
const overlay = document.createElement('div');
overlay.id = SETTINGS_OVERLAY_ID;
const modal = document.createElement('div');
modal.id = SETTINGS_MODAL_ID;
const top = document.createElement('div');
top.className = 'ssp-top';
top.innerHTML = `
<h2>Stylish Search — Settings</h2>
<div class="ssp-muted">Enable/disable, add or remove sites</div>
`;
const actionsHeader = document.createElement('div');
actionsHeader.className = 'ssp-actions';
const leftSide = document.createElement('div');
const rightSide = document.createElement('div');
rightSide.className = 'ssp-actions-right';
const btnAddSite = document.createElement('button');
btnAddSite.className = 'ssp-primary';
btnAddSite.textContent = 'Add site';
const btnSelectAll = document.createElement('button');
btnSelectAll.className = 'ssp-btn';
btnSelectAll.textContent = 'Select all';
const btnSelectNone = document.createElement('button');
btnSelectNone.className = 'ssp-btn';
btnSelectNone.textContent = 'Clear all';
rightSide.append(btnAddSite, btnSelectAll, btnSelectNone);
actionsHeader.append(leftSide, rightSide);
const grid = document.createElement('div');
grid.className = 'ssp-grid';
// Editor overlay (in-modal)
const editorOverlay = document.createElement('div');
editorOverlay.className = 'ssp-editor';
const editorCard = document.createElement('div');
editorCard.className = 'ssp-editor-card';
editorOverlay.appendChild(editorCard);
// Work on copies until Save is clicked
let workingEngines = allEngines.map(e => ({ ...e }));
let workingEnabledSet = new Set(enabledEngineKeys);
function renderGrid() {
grid.innerHTML = '';
const currentlyEnabled = workingEnabledSet;
workingEngines.forEach(engine => {
const item = document.createElement('div');
item.className = 'ssp-item';
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = currentlyEnabled.has(engine.key);
checkbox.addEventListener('change', () => {
if (checkbox.checked) currentlyEnabled.add(engine.key);
else currentlyEnabled.delete(engine.key);
});
const img = createIconImgForEngine(engine);
const span = document.createElement('span');
span.textContent = engine.name;
label.append(checkbox, img, span);
const rowActions = document.createElement('div');
rowActions.className = 'ssp-row-actions';
const editBtn = document.createElement('button');
editBtn.className = 'ssp-icon-btn';
editBtn.title = 'Edit';
editBtn.textContent = '✏️';
editBtn.addEventListener('click', () => openEngineEditor(engine));
const delBtn = document.createElement('button');
delBtn.className = 'ssp-icon-btn';
delBtn.title = 'Delete';
delBtn.textContent = '🗑️';
delBtn.addEventListener('click', () => {
if (!confirm(`Delete "${engine.name}"?`)) return;
workingEngines = workingEngines.filter(e => e.key !== engine.key);
currentlyEnabled.delete(engine.key);
renderGrid();
});
rowActions.append(editBtn, delBtn);
item.append(label, rowActions);
grid.appendChild(item);
});
}
function openEngineEditor(engine) {
editorCard.innerHTML = '';
const isEdit = !!engine;
const title = document.createElement('h3');
title.textContent = isEdit ? 'Edit site' : 'Add site';
title.style.margin = '0 0 8px';
const form = document.createElement('div');
form.className = 'ssp-form';
const nameWrap = document.createElement('label');
nameWrap.innerHTML = `<span>Name</span>`;
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'e.g., Stack Overflow';
nameInput.value = isEdit ? engine.name : '';
nameWrap.appendChild(nameInput);
const hostWrap = document.createElement('label');
hostWrap.innerHTML = `<span>Icon host (used for /favicon.ico)</span>`;
const hostInput = document.createElement('input');
hostInput.type = 'text';
hostInput.placeholder = 'e.g., stackoverflow.com';
hostInput.value = isEdit ? engine.iconHost : '';
hostWrap.appendChild(hostInput);
const urlWrap = document.createElement('label');
urlWrap.innerHTML = `<span>Search URL (use {query})</span>`;
const urlInput = document.createElement('input');
urlInput.type = 'text';
urlInput.placeholder = 'e.g., https://stackoverflow.com/search?q={query}';
urlInput.value = isEdit ? engine.url : '';
urlWrap.appendChild(urlInput);
const hint = document.createElement('div');
hint.className = 'hint';
hint.textContent = 'Example: https://example.com/search?q={query}';
const editorActions = document.createElement('div');
editorActions.className = 'editor-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ssp-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => {
editorOverlay.classList.remove('visible');
});
const saveBtn = document.createElement('button');
saveBtn.className = 'ssp-primary';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async () => {
const name = nameInput.value.trim();
const iconHostRaw = hostInput.value.trim();
const iconHost = normalizeHost(iconHostRaw);
const url = urlInput.value.trim();
if (!name) return alert('Name is required');
if (!iconHost) return alert('Icon host is required');
if (!url || !url.includes('{query}')) return alert('URL must contain {query} placeholder');
if (isEdit) {
const idx = workingEngines.findIndex(e => e.key === engine.key);
if (idx !== -1) {
workingEngines[idx] = { ...workingEngines[idx], name, iconHost, url };
}
preloadIcons([{ key: engine.key, iconHost }]).catch(() => {});
} else {
const key = generateUniqueKey(iconHost || name, workingEngines.map(e => e.key));
const newEngine = { key, name, iconHost, url };
workingEngines.push(newEngine);
workingEnabledSet.add(key);
preloadIcons([newEngine]).catch(() => {});
}
renderGrid();
editorOverlay.classList.remove('visible');
});
editorActions.append(cancelBtn, saveBtn);
form.append(nameWrap, hostWrap, urlWrap, hint, editorActions);
editorCard.append(title, form);
editorOverlay.classList.add('visible');
}
// Controls
btnAddSite.addEventListener('click', () => openEngineEditor(null));
btnSelectAll.addEventListener('click', () => {
workingEngines.forEach(e => workingEnabledSet.add(e.key));
renderGrid();
});
btnSelectNone.addEventListener('click', () => {
workingEnabledSet.clear();
renderGrid();
});
const actionsFooter = document.createElement('div');
actionsFooter.className = 'ssp-actions';
const footerLeft = document.createElement('div');
footerLeft.className = 'ssp-actions-left';
const optionsWrapper = document.createElement('div');
optionsWrapper.style.display = 'flex';
optionsWrapper.style.flexDirection = 'column';
optionsWrapper.style.gap = '8px';
const reuseTabLabel = document.createElement('label');
reuseTabLabel.className = 'ssp-options-label';
const reuseTabCheckbox = document.createElement('input');
reuseTabCheckbox.type = 'checkbox';
reuseTabCheckbox.checked = reuseTabEnabled;
reuseTabLabel.append(reuseTabCheckbox, 'Open in existing tab (if open)');
const showCopyLabel = document.createElement('label');
showCopyLabel.className = 'ssp-options-label';
const showCopyCheckbox = document.createElement('input');
showCopyCheckbox.type = 'checkbox';
showCopyCheckbox.checked = showCopyButtonEnabled;
showCopyLabel.append(showCopyCheckbox, "Show 'Copy' button");
const autoCopyOnSelectLabel = document.createElement('label');
autoCopyOnSelectLabel.className = 'ssp-options-label';
const autoCopyOnSelectCheckbox = document.createElement('input');
autoCopyOnSelectCheckbox.type = 'checkbox';
autoCopyOnSelectCheckbox.checked = autoCopyOnSelectEnabled;
autoCopyOnSelectLabel.append(autoCopyOnSelectCheckbox, "Auto-copy selected text");
optionsWrapper.append(reuseTabLabel, showCopyLabel, autoCopyOnSelectLabel);
const importExportGroup = document.createElement('div');
importExportGroup.className = 'ssp-btn-group';
importExportGroup.style.marginTop = '10px';
const btnImport = document.createElement('button');
btnImport.className = 'ssp-btn';
btnImport.textContent = 'Import';
btnImport.addEventListener('click', handleImport);
const btnExport = document.createElement('button');
btnExport.className = 'ssp-btn';
btnExport.textContent = 'Export';
btnExport.addEventListener('click', handleExport);
importExportGroup.append(btnImport, btnExport);
footerLeft.append(optionsWrapper, importExportGroup);
function handleExport() {
const settingsToExport = {
version: 1,
engines: allEngines,
enabledEngineKeys: enabledEngineKeys,
reuseTab: reuseTabEnabled,
showCopyButton: showCopyButtonEnabled,
autoCopyOnSelect: autoCopyOnSelectEnabled,
};
const dataStr = JSON.stringify(settingsToExport, null, 2);
const blob = new Blob([dataStr], {type: "application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'stylish-search-settings.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target.result);
if (!imported || !Array.isArray(imported.engines) || !Array.isArray(imported.enabledEngineKeys)) {
throw new Error('Invalid file structure.');
}
workingEngines = validateEngines(imported.engines);
workingEnabledSet = new Set(imported.enabledEngineKeys);
reuseTabCheckbox.checked = !!imported.reuseTab;
showCopyCheckbox.checked = typeof imported.showCopyButton === 'boolean' ? imported.showCopyButton : true;
autoCopyOnSelectCheckbox.checked = typeof imported.autoCopyOnSelect === 'boolean' ? imported.autoCopyOnSelect : false;
renderGrid();
alert('Settings loaded. Click "Save" to apply the changes.');
} catch (err) {
alert(`Error importing settings: ${err.message}`);
}
};
reader.readAsText(file);
});
input.click();
}
const footerRight = document.createElement('div');
footerRight.className = 'ssp-actions-right';
const btnCancel = document.createElement('button');
btnCancel.className = 'ssp-btn';
btnCancel.textContent = 'Cancel';
btnCancel.addEventListener('click', closeSettingsModal);
const btnSave = document.createElement('button');
btnSave.className = 'ssp-primary';
btnSave.textContent = 'Save';
btnSave.addEventListener('click', async () => {
const reuseTab = reuseTabCheckbox.checked;
const showCopy = showCopyCheckbox.checked;
const autoCopy = autoCopyOnSelectCheckbox.checked;
await saveAll(workingEngines, Array.from(workingEnabledSet), reuseTab, showCopy, autoCopy);
closeSettingsModal();
});
footerRight.append(btnCancel, btnSave);
actionsFooter.append(footerLeft, footerRight);
modal.append(top, actionsHeader, grid, actionsFooter, editorOverlay);
overlay.appendChild(modal);
overlay.addEventListener('mousedown', (e) => {
if (e.target === overlay) closeSettingsModal();
});
document.body.appendChild(overlay);
renderGrid();
}
function closeSettingsModal() {
const existing = document.getElementById(SETTINGS_OVERLAY_ID);
if (existing) existing.remove();
}
document.addEventListener('mouseup', function (e) {
if (e.target.closest(`#${POPUP_ID}`)) return;
setTimeout(() => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (autoCopyOnSelectEnabled && selectedText.length > 0) {
copyToClipboard(selectedText);
}
if (selectedText.length > 2) {
createPopup(e.pageX, e.pageY, selectedText);
} else {
hidePopup();
}
}, 50);
});
document.addEventListener('mousedown', function (e) {
if (!e.target.closest(`#${POPUP_ID}`)) hidePopup();
});
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Stylish Search: Settings', openSettingsModal);
}
// Inter-tab communication
let lastProcessedRequestId = null;
GM.addValueChangeListener(STORAGE_KEYS.searchRequest, (key, oldValue, newValue, remote) => {
if (!newValue || !remote || newValue.id === lastProcessedRequestId) {
return;
}
const targetHost = newValue.host;
const currentHost = location.hostname;
if (currentHost === targetHost || `www.${currentHost}` === targetHost || currentHost === `www.${targetHost}`) {
lastProcessedRequestId = newValue.id;
GM.setValue(STORAGE_KEYS.searchRequest, { ...newValue, handled: true });
window.location.href = newValue.url;
}
});
GM.addValueChangeListener(STORAGE_KEYS.searchRequest, (key, oldValue, newValue, remote) => {
if (newValue && newValue.id === searchRequestPending && newValue.handled) {
searchRequestPending = null;
}
});
})();